From 6c2c0b39eecc0815e5e31fbb29810037720baef8 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 001/349] chore: Bump minSdk to 30 --- app/build.gradle.kts | 2 +- .../com/flxrs/dankchat/DankChatApplication.kt | 7 +- .../com/flxrs/dankchat/chat/ChatAdapter.kt | 27 -- .../com/flxrs/dankchat/chat/ChatFragment.kt | 150 +++--- .../com/flxrs/dankchat/chat/ChatViewModel.kt | 34 ++ .../chat/compose/ChatMessageMapper.kt | 445 ++++++++++++++++++ .../dankchat/chat/compose/ChatMessageText.kt | 93 ++++ .../chat/compose/ChatMessageUiState.kt | 183 +++++++ .../flxrs/dankchat/chat/compose/ChatScreen.kt | 160 +++++++ .../messages/ComplexMessageComposables.kt | 182 +++++++ .../compose/messages/PrivMessageComposable.kt | 247 ++++++++++ .../messages/SimpleMessageComposables.kt | 108 +++++ .../chat/mention/MentionChatFragment.kt | 4 - .../chat/replies/RepliesChatFragment.kt | 4 - .../data/notification/NotificationService.kt | 33 +- .../data/repo/emote/EmoteRepository.kt | 2 - .../com/flxrs/dankchat/main/DankChatInput.kt | 5 +- .../com/flxrs/dankchat/main/MainActivity.kt | 3 +- .../appearance/AppearanceSettingsFragment.kt | 28 +- .../com/flxrs/dankchat/utils/MultiCallback.kt | 65 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 21 files changed, 1572 insertions(+), 212 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/MultiCallback.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b7861657..aeced4646 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,7 @@ android { defaultConfig { applicationId = "com.flxrs.dankchat" - minSdk = 23 + minSdk = 30 targetSdk = 35 versionCode = 31110 versionName = "3.11.10" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 03100d267..c7cbe1d9b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -89,11 +89,8 @@ class DankChatApplication : Application(), SingletonImageLoader.Factory { .build() } .components { - val decoder = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> AnimatedImageDecoder.Factory() - else -> GifDecoder.Factory() //GifDrawableDecoder.Factory() - } - add(decoder) + // minSdk 30 guarantees AnimatedImageDecoder support (API 28+) + add(AnimatedImageDecoder.Factory()) val client = HttpClient(OkHttp) { install(UserAgent) { agent = "dankchat/${BuildConfig.VERSION_NAME}" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt index 74223132c..6ac5b9cc3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt @@ -150,11 +150,6 @@ class ChatAdapter( 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) } @@ -162,9 +157,6 @@ class ChatAdapter( 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) @@ -489,7 +481,6 @@ class ChatAdapter( // todo extract common badges + emote handling val animateGifs = chatSettings.animateGifs - var hasAnimatedEmoteOrBadge = false holder.scope.launch(holder.coroutineHandler) { allowedBadges.forEachIndexed { idx, badge -> ensureActive() @@ -501,7 +492,6 @@ class ChatAdapter( cached != null -> cached.also { if (it is Animatable) { it.setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true } } @@ -520,7 +510,6 @@ class ChatAdapter( if (this is Animatable) { emoteRepository.badgeCache.put(cacheKey, this) setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true } } } @@ -550,7 +539,6 @@ class ChatAdapter( 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) @@ -562,10 +550,6 @@ class ChatAdapter( 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 } @@ -694,7 +678,6 @@ class ChatAdapter( setText(spannableWithEmojis, TextView.BufferType.SPANNABLE) val animateGifs = chatSettings.animateGifs - var hasAnimatedEmoteOrBadge = false holder.scope.launch(holder.coroutineHandler) { allowedBadges.forEachIndexed { idx, badge -> try { @@ -706,7 +689,6 @@ class ChatAdapter( cached != null -> cached.also { if (it is Animatable) { it.setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true } } @@ -735,7 +717,6 @@ class ChatAdapter( if (this is Animatable && cacheKey != null) { emoteRepository.badgeCache.put(cacheKey, this) setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true } } } @@ -766,7 +747,6 @@ class ChatAdapter( 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) @@ -778,10 +758,6 @@ class ChatAdapter( 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 } @@ -809,9 +785,6 @@ class ChatAdapter( } 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) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt index e7d235776..25a5b76e4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt @@ -8,14 +8,21 @@ import android.text.style.ImageSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName @@ -47,6 +54,7 @@ open class ChatFragment : Fragment() { protected val chatSettingsDataStore: ChatSettingsDataStore by inject() protected val dankChatPreferenceStore: DankChatPreferenceStore by inject() + // Legacy support - will be removed protected var bindingRef: ChatFragmentBinding? = null protected val binding get() = bindingRef!! protected open lateinit var adapter: ChatAdapter @@ -54,24 +62,55 @@ open class ChatFragment : Fragment() { // TODO move to viewmodel? protected open var isAtBottom = true + + private var useCompose = true // Feature flag for migration 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) + return if (useCompose) { + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value + + ChatScreen( + messages = messages, + fontSize = appearanceSettings.fontSize.toFloat(), + modifier = Modifier.fillMaxSize(), + onUserClick = ::onUserClickCompose, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) + }, + onEmoteClick = ::onEmoteClickCompose, + onReplyClick = ::onReplyClick + ) + } + } + } else { + // Legacy RecyclerView implementation + 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 + collectFlow(viewModel.chat) { adapter.submitList(it) } + binding.root + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (useCompose) { + // Compose implementation doesn't need additional setup + return + } + + // Legacy setup val itemDecoration = DividerItemDecoration(view.context, LinearLayoutManager.VERTICAL) manager = LinearLayoutManager(view.context, RecyclerView.VERTICAL, false).apply { stackFromEnd = true } adapter = ChatAdapter( @@ -84,7 +123,9 @@ open class ChatFragment : Fragment() { onUserClick = ::onUserClick, onMessageLongClick = ::onMessageClick, onReplyClick = ::onReplyClick, - onEmoteClick = ::onEmoteClick, + onEmoteClick = { emotes -> + (parentFragment as? MainFragment)?.openEmoteSheet(emotes) + }, ).apply { stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY } binding.chat.setup(adapter, manager) ViewCompat.setWindowInsetsAnimationCallback( @@ -109,45 +150,47 @@ open class ChatFragment : Fragment() { 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) - } - } + // minSdk 30+ handles GIF animations natively } override fun onDestroyView() { - binding.chat.adapter = null - binding.chat.layoutManager = null - - bindingRef = null + if (!useCompose) { + 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) - } - + // minSdk 30+ handles animated drawables automatically super.onStop() } - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - savedInstanceState?.let { - isAtBottom = it.getBoolean(AT_BOTTOM_STATE) - binding.scrollBottom.isVisible = !isAtBottom - } + // Compose-specific callbacks + private fun onUserClickCompose( + targetUserId: String?, + targetUserName: String, + targetDisplayName: String, + channel: String?, + badges: List, + isLongPress: Boolean + ) { + val userId = targetUserId?.let { UserId(it) } + val userName = UserName(targetUserName) + val displayName = DisplayName(targetDisplayName) + val channelName = channel?.let { UserName(it) } + onUserClick(userId, userName, displayName, channelName, emptyList(), isLongPress) } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putBoolean(AT_BOTTOM_STATE, isAtBottom) + private fun onEmoteClickCompose(emotes: List) { + // Convert back to ChatMessageEmote if needed + val chatEmotes = emotes.filterIsInstance() + (parentFragment as? MainFragment)?.openEmoteSheet(chatEmotes) + } + + private fun onReplyClick(rootMessageId: String) { + (parentFragment as? MainFragment)?.openReplies(rootMessageId) } protected open fun onUserClick( @@ -179,15 +222,26 @@ open class ChatFragment : Fragment() { (parentFragment as? MainFragment)?.openMessageSheet(messageId, channel, fullMessage, canReply = true, canModerate = true) } - protected open fun onEmoteClick(emotes: List) { - (parentFragment as? MainFragment)?.openEmoteSheet(emotes) + // Legacy RecyclerView methods + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + if (!useCompose) { + savedInstanceState?.let { + isAtBottom = it.getBoolean(AT_BOTTOM_STATE) + binding.scrollBottom.isVisible = !isAtBottom + } + } } - private fun onReplyClick(rootMessageId: String) { - (parentFragment as? MainFragment)?.openReplies(rootMessageId) + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (!useCompose) { + outState.putBoolean(AT_BOTTOM_STATE, isAtBottom) + } } protected open fun scrollToPosition(position: Int) { + if (useCompose) return bindingRef ?: return if (position > 0 && isAtBottom) { manager.scrollToPositionWithOffset(position, 0) @@ -219,22 +273,12 @@ open class ChatFragment : Fragment() { } } - 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/ChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt index 19f43fcb4..cb145506f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt @@ -1,12 +1,20 @@ package com.flxrs.dankchat.chat +import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.compose.ChatMessageMapper +import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel @@ -16,12 +24,38 @@ import kotlin.time.Duration.Companion.seconds class ChatViewModel( savedStateHandle: SavedStateHandle, repository: ChatRepository, + private val context: Context, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { private val args = ChatFragmentArgs.fromSavedStateHandle(savedStateHandle) + val chat: StateFlow> = (args.channel?.let(repository::getChat) ?: emptyFlow()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) + // Compose UI states + val chatUiStates: StateFlow> = combine( + chat, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings + ) { messages, appearanceSettings, chatSettings -> + var messageCount = 0 + messages.mapIndexed { index, item -> + val isAlternateBackground = when (index) { + messages.lastIndex -> messageCount++.isEven + else -> (index - messages.size - 1).isEven + } + + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground && appearanceSettings.checkeredMessages + ) + } + }.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/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt new file mode 100644 index 000000000..02ac234fc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -0,0 +1,445 @@ +package com.flxrs.dankchat.chat.compose + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.ChatImportance +import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType +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.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.preferences.appearance.AppearanceSettings +import com.flxrs.dankchat.preferences.chat.ChatSettings +import com.flxrs.dankchat.utils.DateTimeUtils +import com.google.android.material.color.MaterialColors + +/** + * Maps domain Message objects to Compose UI state objects. + * Pre-computes all rendering decisions to minimize work during composition. + */ +object ChatMessageMapper { + + fun ChatItem.toChatMessageUiState( + context: Context, + appearanceSettings: AppearanceSettings, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + ): ChatMessageUiState { + val textAlpha = when (importance) { + ChatImportance.SYSTEM -> 0.75f + ChatImportance.DELETED -> 0.5f + ChatImportance.REGULAR -> 1f + } + + return when (val msg = message) { + is SystemMessage -> msg.toSystemMessageUi( + context = context, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha + ) + is NoticeMessage -> msg.toNoticeMessageUi( + context = context, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha + ) + is UserNoticeMessage -> msg.toUserNoticeMessageUi( + context = context, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha + ) + is PrivMessage -> msg.toPrivMessageUi( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + isMentionTab = isMentionTab, + isInReplies = isInReplies, + textAlpha = textAlpha + ) + is ModerationMessage -> msg.toModerationMessageUi( + context = context, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha + ) + is PointRedemptionMessage -> msg.toPointRedemptionMessageUi( + context = context, + chatSettings = chatSettings, + textAlpha = textAlpha + ) + is WhisperMessage -> msg.toWhisperMessageUi( + context = context, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha + ) + } + } + + private fun SystemMessage.toSystemMessageUi( + context: Context, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.SystemMessageUi { + val backgroundColor = calculateCheckeredBackground(context, isAlternateBackground, false) + val timestamp = if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else "" + + val message = when (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, type.status) + is SystemMessageType.ChannelFFZEmotesFailed -> context.getString(R.string.system_message_ffz_emotes_failed, type.status) + is SystemMessageType.ChannelSevenTVEmotesFailed -> context.getString(R.string.system_message_7tv_emotes_failed, type.status) + is SystemMessageType.Custom -> type.message + is SystemMessageType.MessageHistoryUnavailable -> when (type.status) { + null -> context.getString(R.string.system_message_history_unavailable) + else -> context.getString(R.string.system_message_history_unavailable_detailed, type.status) + } + is SystemMessageType.ChannelSevenTVEmoteAdded -> context.getString(R.string.system_message_7tv_emote_added, type.actorName, type.emoteName) + is SystemMessageType.ChannelSevenTVEmoteRemoved -> context.getString(R.string.system_message_7tv_emote_removed, type.actorName, type.emoteName) + is SystemMessageType.ChannelSevenTVEmoteRenamed -> context.getString( + R.string.system_message_7tv_emote_renamed, + type.actorName, + type.oldEmoteName, + type.emoteName + ) + is SystemMessageType.ChannelSevenTVEmoteSetChanged -> context.getString(R.string.system_message_7tv_emote_set_changed, type.actorName, type.newEmoteSetName) + } + + return ChatMessageUiState.SystemMessageUi( + id = id, + timestamp = timestamp, + backgroundColor = backgroundColor, + textAlpha = textAlpha, + message = message + ) + } + + private fun NoticeMessage.toNoticeMessageUi( + context: Context, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.NoticeMessageUi { + val backgroundColor = calculateCheckeredBackground(context, isAlternateBackground, false) + val timestamp = if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else "" + + return ChatMessageUiState.NoticeMessageUi( + id = id, + timestamp = timestamp, + backgroundColor = backgroundColor, + textAlpha = textAlpha, + message = message + ) + } + + private fun UserNoticeMessage.toUserNoticeMessageUi( + context: Context, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.UserNoticeMessageUi { + val shouldHighlight = highlights.any { + it.type == com.flxrs.dankchat.data.twitch.message.HighlightType.Subscription || + it.type == com.flxrs.dankchat.data.twitch.message.HighlightType.Announcement + } + val backgroundColor = when { + shouldHighlight -> ContextCompat.getColor(context, R.color.color_sub_highlight) + else -> calculateCheckeredBackground(context, isAlternateBackground, false) + } + val timestamp = if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else "" + + return ChatMessageUiState.UserNoticeMessageUi( + id = id, + timestamp = timestamp, + backgroundColor = backgroundColor, + textAlpha = textAlpha, + message = message, + shouldHighlight = shouldHighlight + ) + } + + private fun ModerationMessage.toModerationMessageUi( + context: Context, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.ModerationMessageUi { + val backgroundColor = calculateCheckeredBackground(context, isAlternateBackground, false) + val timestamp = if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else "" + + return ChatMessageUiState.ModerationMessageUi( + id = id, + timestamp = timestamp, + backgroundColor = backgroundColor, + textAlpha = textAlpha, + message = "" // TODO: Implement getSystemMessage + ) + } + + private fun PrivMessage.toPrivMessageUi( + context: Context, + appearanceSettings: AppearanceSettings, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + isMentionTab: Boolean, + isInReplies: Boolean, + textAlpha: Float, + ): ChatMessageUiState.PrivMessageUi { + val bgColor = when { + timedOut && !chatSettings.showTimedOutMessages -> Color.TRANSPARENT + highlights.isNotEmpty() -> highlights.toBackgroundColor(context) + else -> calculateCheckeredBackground(context, 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 + ) + } + + 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 + } + } + + EmoteUi( + code = emoteGroup.first().code, + urls = emoteGroup.map { it.url }, + position = position, + isAnimated = hasAnimated, + isTwitch = emoteGroup.any { it.isTwitch }, + scale = emoteGroup.first().scale, + emotes = emoteGroup + ) + } + + val threadUi = if (thread != null && !isInReplies) { + thread.toThreadUi() + } else null + + val fullMessage = buildString { + if (isMentionTab && highlights.any { it.isMention }) { + append("#$channel ") + } + if (timestamp.isNotEmpty()) { + append("$timestamp ") + } + append(nameText) + append(message) + } + + return ChatMessageUiState.PrivMessageUi( + id = id, + timestamp = timestamp, + backgroundColor = bgColor, + textAlpha = textAlpha, + enableRipple = true, + channel = channel, + userId = userId, + userName = name, + displayName = displayName, + badges = badgeUis, + nameColor = customOrUserColorOn(bgColor), + nameText = nameText, + message = message, + emotes = emoteUis, + isAction = isAction, + thread = threadUi, + fullMessage = fullMessage + ) + } + + private fun PointRedemptionMessage.toPointRedemptionMessageUi( + context: Context, + chatSettings: ChatSettings, + textAlpha: Float, + ): ChatMessageUiState.PointRedemptionMessageUi { + val backgroundColor = ContextCompat.getColor(context, R.color.color_redemption_highlight) + val timestamp = if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else "" + + val nameText = if (!requiresUserInput) aliasOrFormattedName else null + + return ChatMessageUiState.PointRedemptionMessageUi( + id = id, + timestamp = timestamp, + backgroundColor = backgroundColor, + textAlpha = textAlpha, + nameText = nameText, + title = title, + cost = cost, + rewardImageUrl = rewardImageUrl, + requiresUserInput = requiresUserInput + ) + } + + private fun WhisperMessage.toWhisperMessageUi( + context: Context, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.WhisperMessageUi { + val backgroundColor = calculateCheckeredBackground(context, 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 + ) + } + + 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 + } + } + + EmoteUi( + code = emoteGroup.first().code, + urls = emoteGroup.map { it.url }, + position = position, + isAnimated = hasAnimated, + isTwitch = emoteGroup.any { it.isTwitch }, + scale = emoteGroup.first().scale, + emotes = emoteGroup + ) + } + + val fullMessage = buildString { + if (timestamp.isNotEmpty()) { + append("$timestamp ") + } + append("$senderAliasOrFormattedName -> $recipientAliasOrFormattedName: ") + append(message) + } + + return ChatMessageUiState.WhisperMessageUi( + id = id, + timestamp = timestamp, + backgroundColor = backgroundColor, + textAlpha = textAlpha, + enableRipple = true, + userId = userId ?: error("Whisper must have userId"), + userName = name, + displayName = displayName, + badges = badgeUis, + senderColor = senderColorOnBackground(backgroundColor), + recipientColor = recipientColorOnBackground(backgroundColor), + senderName = senderAliasOrFormattedName, + recipientName = recipientAliasOrFormattedName, + message = message, + emotes = emoteUis, + fullMessage = fullMessage + ) + } + + private fun calculateCheckeredBackground( + context: Context, + isAlternateBackground: Boolean, + enableCheckered: Boolean, // Will be controlled by settings + ): Int { + return when { + enableCheckered && isAlternateBackground -> { + // Manual calculation since we don't have a View + val backgroundColor = android.graphics.Color.TRANSPARENT + val surfaceInverse = ContextCompat.getColor(context, android.R.color.white) + // Use alpha blending for checkered effect + android.graphics.Color.argb( + (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), + android.graphics.Color.red(surfaceInverse), + android.graphics.Color.green(surfaceInverse), + android.graphics.Color.blue(surfaceInverse) + ) + } + else -> ContextCompat.getColor(context, android.R.color.transparent) + } + } + + @ColorInt + private fun Set.toBackgroundColor(context: Context): Int { + val highlight = this.maxByOrNull { it.type.priority.value } + ?: return ContextCompat.getColor(context, android.R.color.transparent) + return when (highlight.type) { + com.flxrs.dankchat.data.twitch.message.HighlightType.Subscription, + com.flxrs.dankchat.data.twitch.message.HighlightType.Announcement -> ContextCompat.getColor(context, R.color.color_sub_highlight) + com.flxrs.dankchat.data.twitch.message.HighlightType.ChannelPointRedemption -> ContextCompat.getColor(context, R.color.color_redemption_highlight) + com.flxrs.dankchat.data.twitch.message.HighlightType.ElevatedMessage -> ContextCompat.getColor(context, R.color.color_elevated_message_highlight) + com.flxrs.dankchat.data.twitch.message.HighlightType.FirstMessage -> ContextCompat.getColor(context, R.color.color_first_message_highlight) + com.flxrs.dankchat.data.twitch.message.HighlightType.Username, + com.flxrs.dankchat.data.twitch.message.HighlightType.Custom, + com.flxrs.dankchat.data.twitch.message.HighlightType.Reply, + com.flxrs.dankchat.data.twitch.message.HighlightType.Notification -> ContextCompat.getColor(context, R.color.color_mention_highlight) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt new file mode 100644 index 000000000..e57b6a1fc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt @@ -0,0 +1,93 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +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.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.withStyle +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Renders a chat message text with support for: + * - Timestamps (monospace, bold) + * - Username colors + * - Emotes and badges (via InlineTextContent) + * - Clickable spans (usernames, links, emotes) + */ +@Composable +fun ChatMessageText( + text: String, + modifier: Modifier = Modifier, + fontSize: TextUnit = 14.sp, + textColor: Color = Color.White, + timestamp: String? = null, + nameText: String? = null, + nameColor: Color = Color.Gray, + isAction: Boolean = false, + inlineContent: Map = emptyMap(), +) { + val annotatedString = remember(text, timestamp, nameText, nameColor, isAction, textColor) { + buildAnnotatedString { + // Add timestamp if present + if (timestamp != null) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = fontSize * 0.95f, + letterSpacing = 0.05.sp + ) + ) { + append(timestamp) + } + append(" ") + } + + // Add username if present + if (nameText != null) { + withStyle( + SpanStyle( + color = nameColor, + fontWeight = FontWeight.Bold + ) + ) { + append(nameText) + } + if (!isAction) { + append(": ") + } else { + append(" ") + } + } + + // Add message text + withStyle( + SpanStyle( + color = if (isAction) nameColor else textColor + ) + ) { + append(text) + } + } + } + + Box(modifier = modifier.padding(horizontal = 8.dp)) { + BasicText( + text = annotatedString, + modifier = Modifier.fillMaxWidth(), + inlineContent = inlineContent + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt new file mode 100644 index 000000000..b5d669aa1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -0,0 +1,183 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.annotation.ColorInt +import androidx.compose.runtime.Immutable +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.MessageThreadHeader + +/** + * UI state for rendering chat messages in Compose. + * All rendering decisions are pre-computed to avoid work during recomposition. + */ +@Immutable +sealed interface ChatMessageUiState { + val id: String + val timestamp: String + val backgroundColor: Int + val textAlpha: Float + val enableRipple: Boolean + + /** + * Regular chat message from a user + */ + @Immutable + data class PrivMessageUi( + override val id: String, + override val timestamp: String, + @ColorInt override val backgroundColor: Int, + override val textAlpha: Float, + override val enableRipple: Boolean, + val channel: UserName, + val userId: UserId?, + val userName: UserName, + val displayName: DisplayName, + val badges: List, + @ColorInt val nameColor: Int, + val nameText: String, + val message: String, + val emotes: List, + val isAction: Boolean, + val thread: ThreadUi?, + val fullMessage: String, // For copying + ) : ChatMessageUiState + + /** + * System messages (connected, disconnected, etc.) + */ + @Immutable + data class SystemMessageUi( + override val id: String, + override val timestamp: String, + @ColorInt override val backgroundColor: Int, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + val message: String, + ) : ChatMessageUiState + + /** + * Notice messages from Twitch + */ + @Immutable + data class NoticeMessageUi( + override val id: String, + override val timestamp: String, + @ColorInt override val backgroundColor: Int, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + val message: String, + ) : ChatMessageUiState + + /** + * User notice messages (subscriptions, etc.) + */ + @Immutable + data class UserNoticeMessageUi( + override val id: String, + override val timestamp: String, + @ColorInt override val backgroundColor: Int, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + val message: String, + val shouldHighlight: Boolean, + ) : ChatMessageUiState + + /** + * Moderation messages (timeouts, bans, etc.) + */ + @Immutable + data class ModerationMessageUi( + override val id: String, + override val timestamp: String, + @ColorInt override val backgroundColor: Int, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + val message: String, + ) : ChatMessageUiState + + /** + * Channel point redemption messages + */ + @Immutable + data class PointRedemptionMessageUi( + override val id: String, + override val timestamp: String, + @ColorInt override val backgroundColor: Int, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + val nameText: String?, + val title: String, + val cost: Int, + val rewardImageUrl: String, + val requiresUserInput: Boolean, + ) : ChatMessageUiState + + /** + * Whisper messages + */ + @Immutable + data class WhisperMessageUi( + override val id: String, + override val timestamp: String, + @ColorInt override val backgroundColor: Int, + override val textAlpha: Float, + override val enableRipple: Boolean, + val userId: UserId, + val userName: UserName, + val displayName: DisplayName, + val badges: List, + @ColorInt val senderColor: Int, + @ColorInt val recipientColor: Int, + val senderName: String, + val recipientName: String, + val message: String, + val emotes: List, + val fullMessage: String, + ) : ChatMessageUiState +} + +/** + * UI state for badges + */ +@Immutable +data class BadgeUi( + val url: String, + val badge: Badge, + val position: Int, // Position in message +) + +/** + * UI state for emotes + */ +@Immutable +data class EmoteUi( + val code: String, + val urls: List, + val position: IntRange, + val isAnimated: Boolean, + val isTwitch: Boolean, + val scale: Int, + val emotes: List, // For click handling +) + +/** + * UI state for reply threads + */ +@Immutable +data class ThreadUi( + val rootId: String, + val userName: String, + val message: String, +) + +/** + * Converts MessageThreadHeader to ThreadUi + */ +fun MessageThreadHeader.toThreadUi(): ThreadUi = ThreadUi( + rootId = rootId, + userName = name.value, + message = message, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt new file mode 100644 index 000000000..67bd3f23b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -0,0 +1,160 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.chat.compose.messages.ModerationMessageComposable +import com.flxrs.dankchat.chat.compose.messages.NoticeMessageComposable +import com.flxrs.dankchat.chat.compose.messages.PointRedemptionMessageComposable +import com.flxrs.dankchat.chat.compose.messages.PrivMessageComposable +import com.flxrs.dankchat.chat.compose.messages.SystemMessageComposable +import com.flxrs.dankchat.chat.compose.messages.UserNoticeMessageComposable +import com.flxrs.dankchat.chat.compose.messages.WhisperMessageComposable + +/** + * Main composable for rendering chat messages in a scrollable list. + * + * Features: + * - LazyColumn with reverseLayout for bottom-anchored scrolling + * - Automatic scroll to bottom when new messages arrive + * - FAB to manually scroll to bottom + * - Efficient recomposition with stable keys + */ +@Composable +fun ChatScreen( + messages: List, + fontSize: Float = 14f, + modifier: Modifier = Modifier, + 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) -> Unit = {}, +) { + val listState = rememberLazyListState() + + // Track if user is at bottom + val isAtBottom by remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() + lastVisibleItem?.index == listState.layoutInfo.totalItemsCount - 1 + } + } + + // Auto-scroll to bottom when new messages arrive and user is already at bottom + LaunchedEffect(messages.size) { + if (isAtBottom && messages.isNotEmpty()) { + listState.scrollToItem(messages.size - 1) + } + } + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier.fillMaxSize() + ) { + items( + items = messages.asReversed(), + key = { message -> message.id } + ) { message -> + ChatMessageItem( + message = message, + fontSize = fontSize, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick, + ) + } + } + + // Scroll to bottom FAB + if (!isAtBottom && messages.isNotEmpty()) { + FloatingActionButton( + onClick = { + // TODO: Smooth scroll + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom" + ) + } + } + } +} + +/** + * Renders a single chat message based on its type + */ +@Composable +private fun ChatMessageItem( + message: ChatMessageUiState, + 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) -> Unit, +) { + when (message) { + is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( + message = message, + fontSize = fontSize + ) + is ChatMessageUiState.NoticeMessageUi -> NoticeMessageComposable( + message = message, + fontSize = fontSize + ) + is ChatMessageUiState.UserNoticeMessageUi -> UserNoticeMessageComposable( + message = message, + fontSize = fontSize + ) + is ChatMessageUiState.ModerationMessageUi -> ModerationMessageComposable( + message = message, + fontSize = fontSize + ) + is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( + message = message, + fontSize = fontSize, + onUserClick = { userId, userName, displayName, channel, isLongPress -> + onUserClick(userId, userName, displayName, channel, emptyList(), isLongPress) + }, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick + ) + is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( + message = message, + fontSize = fontSize + ) + is ChatMessageUiState.WhisperMessageUi -> WhisperMessageComposable( + message = message, + fontSize = fontSize, + onUserClick = { userId, userName, displayName, isLongPress -> + onUserClick(userId, userName, displayName, null, emptyList(), isLongPress) + }, + onMessageLongClick = { messageId, fullMessage -> + onMessageLongClick(messageId, null, fullMessage) + } + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt new file mode 100644 index 000000000..5b76cdce3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt @@ -0,0 +1,182 @@ +package com.flxrs.dankchat.chat.compose.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +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.runtime.Composable +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.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.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.flxrs.dankchat.chat.compose.ChatMessageUiState + +/** + * Renders a whisper message (private message between users) + */ +@Composable +fun WhisperMessageComposable( + message: ChatMessageUiState.WhisperMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + onUserClick: (userId: String?, userName: String, displayName: String, isLongPress: Boolean) -> Unit = { _, _, _, _ -> }, + onMessageLongClick: (messageId: String, fullMessage: String) -> Unit = { _, _ -> }, +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color(message.backgroundColor)) + .combinedClickable( + onClick = {}, + onLongClick = { + onMessageLongClick(message.id, message.fullMessage) + } + ) + .padding(horizontal = 8.dp) + ) { + val annotatedString = remember(message) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + letterSpacing = 0.05.sp + ) + ) { + append(message.timestamp) + } + append(" ") + } + + // Badges (simplified for now) + message.badges.forEach { _ -> + append("⠀ ") + } + + // Sender + withStyle( + SpanStyle( + color = Color(message.senderColor), + fontWeight = FontWeight.Bold + ) + ) { + append(message.senderName) + } + append(" -> ") + + // Recipient + withStyle( + SpanStyle( + color = Color(message.recipientColor), + fontWeight = FontWeight.Bold + ) + ) { + append(message.recipientName) + } + append(": ") + + // Message + withStyle(SpanStyle(color = Color.White.copy(alpha = message.textAlpha))) { + append(message.message) + } + } + } + + BasicText( + text = annotatedString, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * Renders a channel point redemption message + */ +@Composable +fun PointRedemptionMessageComposable( + message: ChatMessageUiState.PointRedemptionMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color(message.backgroundColor)) + .padding(horizontal = 8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + val annotatedString = remember(message) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + letterSpacing = 0.05.sp + ) + ) { + append(message.timestamp) + } + append(" ") + } + + when { + message.requiresUserInput -> { + append("Redeemed ") + } + message.nameText != null -> { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(message.nameText) + } + append(" redeemed ") + } + } + + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(message.title) + } + append(" ") + } + } + + BasicText( + text = annotatedString, + modifier = Modifier.weight(1f) + ) + + AsyncImage( + model = message.rewardImageUrl, + contentDescription = message.title, + modifier = Modifier.size((fontSize * 1.5f).dp) + ) + + BasicText( + text = " ${message.cost}", + modifier = Modifier.padding(start = 4.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt new file mode 100644 index 000000000..9c28e58ae --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt @@ -0,0 +1,247 @@ +package com.flxrs.dankchat.chat.compose.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +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.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +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.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 com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.EmoteUi + +/** + * 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 + */ +@Composable +fun PrivMessageComposable( + message: ChatMessageUiState.PrivMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, isLongPress: Boolean) -> Unit = { _, _, _, _, _ -> }, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit = { _, _, _ -> }, + onEmoteClick: (emotes: List) -> Unit = {}, + onReplyClick: (rootMessageId: String) -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color(message.backgroundColor)) + .combinedClickable( + onClick = {}, + onLongClick = { + onMessageLongClick(message.id, message.channel.value, message.fullMessage) + } + ) + .padding(horizontal = 8.dp) + ) { + // Reply thread header + if (message.thread != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onReplyClick(message.thread.rootId) } + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Reply, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + Text( + text = "Reply to @${message.thread.userName}: ${message.thread.message}", + fontSize = (fontSize * 0.9f).sp, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + // Main message + PrivMessageText( + message = message, + fontSize = fontSize, + onUserClick = onUserClick, + onEmoteClick = onEmoteClick + ) + } +} + +@Composable +private fun PrivMessageText( + message: ChatMessageUiState.PrivMessageUi, + fontSize: Float, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, isLongPress: Boolean) -> Unit, + onEmoteClick: (emotes: List) -> Unit, +) { + val annotatedString = remember(message) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + letterSpacing = 0.05.sp + ) + ) { + append(message.timestamp) + } + append(" ") + } + + // Badges (placeholders) + message.badges.forEach { badge -> + pushStringAnnotation(tag = "BADGE", annotation = badge.position.toString()) + append("⠀") + pop() + append(" ") + } + + // Username + if (message.nameText.isNotEmpty()) { + pushStringAnnotation(tag = "USERNAME", annotation = message.userId?.value ?: "") + withStyle( + SpanStyle( + color = Color(message.nameColor), + fontWeight = FontWeight.Bold + ) + ) { + append(message.nameText.removeSuffix(": ").removeSuffix(" ")) + } + pop() + + if (message.isAction) { + append(" ") + } else { + append(": ") + } + } + + // Message text with emotes + val textColor = if (message.isAction) { + Color(message.nameColor) + } else { + Color.White.copy(alpha = message.textAlpha) + } + + withStyle(SpanStyle(color = textColor)) { + var currentPos = 0 + message.emotes.sortedBy { it.position.first }.forEach { emote -> + // Text before emote + if (currentPos < emote.position.first) { + append(message.message.substring(currentPos, emote.position.first)) + } + + // Emote placeholder + pushStringAnnotation(tag = "EMOTE", annotation = emote.code) + append("⠀") // Invisible space for emote + pop() + + currentPos = emote.position.last + 1 + } + + // Remaining text + if (currentPos < message.message.length) { + append(message.message.substring(currentPos)) + } + } + } + } + + // Inline content for badges and emotes + val inlineContent = remember(message.badges, message.emotes, fontSize) { + buildInlineContent(message.badges, message.emotes, fontSize, onEmoteClick) + } + + BasicText( + text = annotatedString, + modifier = Modifier.fillMaxWidth(), + inlineContent = inlineContent + ) +} + +private fun buildInlineContent( + badges: List, + emotes: List, + fontSize: Float, + onEmoteClick: (emotes: List) -> Unit, +): Map { + val content = mutableMapOf() + + // Badges + badges.forEach { badge -> + content["BADGE_${badge.position}"] = InlineTextContent( + placeholder = Placeholder( + width = (fontSize * 1.2f).sp, + height = fontSize.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + AsyncImage( + model = badge.url, + contentDescription = null, + modifier = Modifier.size((fontSize * 1.2f).dp) + ) + } + } + + // Emotes + emotes.forEach { emote -> + content["EMOTE_${emote.code}"] = InlineTextContent( + placeholder = Placeholder( + width = (fontSize * 2f).sp, + height = fontSize.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + AsyncImage( + model = emote.urls.firstOrNull(), + contentDescription = emote.code, + modifier = Modifier + .size((fontSize * 2f).dp) + .clickable { onEmoteClick(emote.emotes) } + ) + } + } + + return content +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt new file mode 100644 index 000000000..1f61d1c53 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt @@ -0,0 +1,108 @@ +package com.flxrs.dankchat.chat.compose.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import com.flxrs.dankchat.chat.compose.ChatMessageText +import com.flxrs.dankchat.chat.compose.ChatMessageUiState + +/** + * Renders a system message (connected, disconnected, emote loading failures, etc.) + */ +@Composable +fun SystemMessageComposable( + message: ChatMessageUiState.SystemMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color(message.backgroundColor)) + ) { + ChatMessageText( + text = message.message, + timestamp = message.timestamp, + fontSize = fontSize.sp, + textColor = Color.White.copy(alpha = message.textAlpha), + ) + } +} + +/** + * Renders a notice message from Twitch + */ +@Composable +fun NoticeMessageComposable( + message: ChatMessageUiState.NoticeMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color(message.backgroundColor)) + ) { + ChatMessageText( + text = message.message, + timestamp = message.timestamp, + fontSize = fontSize.sp, + textColor = Color.White.copy(alpha = message.textAlpha), + ) + } +} + +/** + * Renders a user notice message (subscriptions, announcements, etc.) + */ +@Composable +fun UserNoticeMessageComposable( + message: ChatMessageUiState.UserNoticeMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color(message.backgroundColor)) + ) { + ChatMessageText( + text = message.message, + timestamp = message.timestamp, + fontSize = fontSize.sp, + textColor = Color.White.copy(alpha = message.textAlpha), + ) + } +} + +/** + * Renders a moderation message (timeouts, bans, deletions) + */ +@Composable +fun ModerationMessageComposable( + message: ChatMessageUiState.ModerationMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color(message.backgroundColor)) + ) { + ChatMessageText( + text = message.message, + timestamp = message.timestamp, + fontSize = fontSize.sp, + textColor = Color.White.copy(alpha = message.textAlpha), + ) + } +} 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 index ff490b4e7..3d86a9d42 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt @@ -69,10 +69,6 @@ class MentionChatFragment : ChatFragment() { (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/replies/RepliesChatFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt index 810e3bcc6..d874e23a2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt @@ -65,8 +65,4 @@ class RepliesChatFragment : ChatFragment() { 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/data/notification/NotificationService.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt index 51ee2052e..910bdfd59 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 @@ -62,10 +62,8 @@ class NotificationService : Service(), CoroutineScope { 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 - } + // minSdk 30 guarantees PendingIntent.FLAG_IMMUTABLE support (API 23+) + private val pendingIntentFlag: Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT private var activeTTSChannel: UserName? = null private var shouldNotifyOnMention = false @@ -89,19 +87,17 @@ 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 { - enableVibration(false) - enableLights(false) - setShowBadge(false) - } - - val mentionChannel = NotificationChannel(CHANNEL_ID_DEFAULT, "Mentions", NotificationManager.IMPORTANCE_DEFAULT) - manager.createNotificationChannel(mentionChannel) - manager.createNotificationChannel(channel) + // minSdk 30 guarantees notification channel support (API 26+) + 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) notificationsSettingsDataStore.showNotifications .onEach { notificationsEnabled = it } @@ -203,11 +199,8 @@ class NotificationService : Service(), CoroutineScope { .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)) - } - } + .addAction(R.drawable.ic_clear, getString(R.string.notification_stop), pendingStopIntent) + .setStyle(MediaStyle().setShowActionsInCompactView(0)) // minSdk 30 guarantees MediaStyle support .setContentIntent(pendingStartActivityIntent) .setSmallIcon(R.drawable.ic_notification_icon) .build() 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..a34162a76 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 @@ -42,7 +42,6 @@ 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.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.utils.MultiCallback import com.flxrs.dankchat.utils.extensions.appendSpacesBetweenEmojiGroup import com.flxrs.dankchat.utils.extensions.chunkedBy import com.flxrs.dankchat.utils.extensions.concurrentMap @@ -76,7 +75,6 @@ class EmoteRepository( val badgeCache = LruCache(64) val layerCache = LruCache(256) - val gifCallback = MultiCallback() fun getEmotes(channel: UserName): StateFlow = emotes.getOrPut(channel) { MutableStateFlow(Emotes()) } fun createFlowsIfNecessary(channels: List) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInput.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInput.kt index 81dcb23bb..c45ea06ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInput.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInput.kt @@ -15,10 +15,7 @@ class DankChatInput : AppCompatMultiAutoCompleteTextView { 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() - } - + // minSdk 30+ doesn't need back button workaround return super.onKeyPreIme(keyCode, event) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 3f8630c47..4d8d18d73 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -172,7 +172,8 @@ class MainActivity : AppCompatActivity() { val windowInsetsController = WindowCompat.getInsetsController(window, it) when { enabled -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !isInMultiWindowMode) { + // minSdk 30 guarantees multi-window support (API 24+) + if (!isInMultiWindowMode) { with(windowInsetsController) { systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE hide(Type.systemBars()) 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 index 2beb529e2..12f18fc5b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt @@ -302,34 +302,12 @@ data class ThemeState( private fun rememberThemeState(theme: ThemePreference, trueDark: Boolean, systemDarkMode: Boolean): ThemeState { val context = LocalContext.current val defaultEntries = stringArrayResource(R.array.theme_entries).toImmutableList() + // minSdk 30 always supports light mode and system dark mode 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() - } + defaultEntries to ThemePreference.entries.toImmutableList() } return remember(theme, trueDark) { 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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2b5..2e1113280 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 8eb1e3fe12ec476e8b658d624c9a9dbdb099395e Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 002/349] feat(compose): Add core chat message UI components --- .../com/flxrs/dankchat/chat/ChatFragment.kt | 55 ++- .../chat/compose/AdaptiveTextColor.kt | 37 ++ .../dankchat/chat/compose/BackgroundColor.kt | 13 + .../chat/compose/ChatMessageMapper.kt | 260 +++++++++----- .../dankchat/chat/compose/ChatMessageText.kt | 26 +- .../chat/compose/ChatMessageUiState.kt | 35 +- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 173 ++++++---- .../chat/compose/EmoteAnimationCoordinator.kt | 131 +++++++ .../dankchat/chat/compose/EmoteScaling.kt | 96 ++++++ .../dankchat/chat/compose/StackedEmote.kt | 241 +++++++++++++ .../compose/TextWithMeasuredInlineContent.kt | 171 +++++++++ .../messages/ComplexMessageComposables.kt | 182 ---------- .../chat/compose/messages/PrivMessage.kt | 292 ++++++++++++++++ .../compose/messages/PrivMessageComposable.kt | 247 ------------- .../messages/SimpleMessageComposables.kt | 108 ------ .../chat/compose/messages/SystemMessages.kt | 87 +++++ .../compose/messages/WhisperAndRedemption.kt | 326 ++++++++++++++++++ .../compose/messages/common/InlineContent.kt | 49 +++ .../messages/common/MessageTextBuilders.kt | 160 +++++++++ .../messages/common/SimpleMessageContainer.kt | 48 +++ .../chat/mention/MentionChatFragment.kt | 57 ++- .../dankchat/chat/mention/MentionViewModel.kt | 44 ++- .../chat/replies/RepliesChatFragment.kt | 64 +++- .../dankchat/chat/replies/RepliesViewModel.kt | 36 ++ .../com/flxrs/dankchat/theme/DankChatTheme.kt | 51 ++- 25 files changed, 2203 insertions(+), 786 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt index 25a5b76e4..f61d1e206 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt @@ -1,10 +1,6 @@ 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 @@ -16,12 +12,12 @@ import androidx.compose.ui.platform.ViewCompositionStrategy 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId @@ -37,10 +33,8 @@ 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.theme.DankChatTheme 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 @@ -62,7 +56,7 @@ open class ChatFragment : Fragment() { // TODO move to viewmodel? protected open var isAtBottom = true - + private var useCompose = true // Feature flag for migration override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -72,18 +66,22 @@ open class ChatFragment : Fragment() { setContent { val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value - - ChatScreen( - messages = messages, - fontSize = appearanceSettings.fontSize.toFloat(), - modifier = Modifier.fillMaxSize(), - onUserClick = ::onUserClickCompose, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) - }, - onEmoteClick = ::onEmoteClickCompose, - onReplyClick = ::onReplyClick - ) + val chatSettings = chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()).value + DankChatTheme { + ChatScreen( + messages = messages, + fontSize = appearanceSettings.fontSize.toFloat(), + showLineSeparator = appearanceSettings.lineSeparator, + animateGifs = chatSettings.animateGifs, + modifier = Modifier.fillMaxSize(), + onUserClick = ::onUserClickCompose, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) + }, + onEmoteClick = ::onEmoteClickCompose, + onReplyClick = ::onReplyClick + ) + } } } } else { @@ -109,7 +107,7 @@ open class ChatFragment : Fragment() { // Compose implementation doesn't need additional setup return } - + // Legacy setup val itemDecoration = DividerItemDecoration(view.context, LinearLayoutManager.VERTICAL) manager = LinearLayoutManager(view.context, RecyclerView.VERTICAL, false).apply { stackFromEnd = true } @@ -123,7 +121,7 @@ open class ChatFragment : Fragment() { onUserClick = ::onUserClick, onMessageLongClick = ::onMessageClick, onReplyClick = ::onReplyClick, - onEmoteClick = { emotes -> + onEmoteClick = { emotes -> (parentFragment as? MainFragment)?.openEmoteSheet(emotes) }, ).apply { stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY } @@ -173,20 +171,19 @@ open class ChatFragment : Fragment() { targetUserName: String, targetDisplayName: String, channel: String?, - badges: List, + badges: List, isLongPress: Boolean ) { val userId = targetUserId?.let { UserId(it) } val userName = UserName(targetUserName) val displayName = DisplayName(targetDisplayName) val channelName = channel?.let { UserName(it) } - onUserClick(userId, userName, displayName, channelName, emptyList(), isLongPress) + val badgeList = badges.map(BadgeUi::badge) + onUserClick(userId, userName, displayName, channelName, badgeList, isLongPress) } - private fun onEmoteClickCompose(emotes: List) { - // Convert back to ChatMessageEmote if needed - val chatEmotes = emotes.filterIsInstance() - (parentFragment as? MainFragment)?.openEmoteSheet(chatEmotes) + private fun onEmoteClickCompose(emotes: List) { + (parentFragment as? MainFragment)?.openEmoteSheet(emotes) } private fun onReplyClick(rootMessageId: String) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt new file mode 100644 index 000000000..7b67d9372 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt @@ -0,0 +1,37 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.flxrs.dankchat.theme.LocalAdaptiveColors +import com.google.android.material.color.MaterialColors + +/** + * Returns appropriate text color (light or dark) based on background brightness. + * Uses MaterialColors.isColorLight() 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 surfaceColor = MaterialTheme.colorScheme.surface + + // For transparent backgrounds, use surface color for calculation + val effectiveBackground = if (backgroundColor == Color.Transparent) { + surfaceColor + } else { + backgroundColor + } + + val isLightBackground = MaterialColors.isColorLight(effectiveBackground.toArgb()) + + return if (isLightBackground) { + adaptiveColors.onSurfaceLight + } else { + adaptiveColors.onSurfaceDark + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt new file mode 100644 index 000000000..fb2f15c9d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt @@ -0,0 +1,13 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Selects the appropriate background color based on current theme. + */ +@Composable +fun rememberBackgroundColor(lightColor: Color, darkColor: Color): Color { + return if (isSystemInDarkTheme()) darkColor else lightColor +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 02ac234fc..c804abeb3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -1,14 +1,14 @@ package com.flxrs.dankchat.chat.compose import android.content.Context -import android.graphics.Color -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.ChatImportance import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType +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 @@ -34,6 +34,34 @@ import com.google.android.material.color.MaterialColors */ object ChatMessageMapper { + // Highlight colors - Light theme + private val COLOR_SUB_HIGHLIGHT_LIGHT = Color(0xFFD1C4E9) + private val COLOR_MENTION_HIGHLIGHT_LIGHT = Color(0xFFEF9A9A) + private val COLOR_REDEMPTION_HIGHLIGHT_LIGHT = Color(0xFF93F1FF) + private val COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFC2F18D) + private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFFFE087) + + // Highlight colors - Dark theme + private val COLOR_SUB_HIGHLIGHT_DARK = Color(0xFF543589) + private val COLOR_MENTION_HIGHLIGHT_DARK = Color(0xFF773031) + private val COLOR_REDEMPTION_HIGHLIGHT_DARK = Color(0xFF004F57) + private val COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK = Color(0xFF2D5000) + private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK = Color(0xFF574500) + + // Checkered background colors + private val CHECKERED_LIGHT = Color( + android.graphics.Color.argb( + (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), + 0, 0, 0 + ) + ) + private val CHECKERED_DARK = Color( + android.graphics.Color.argb( + (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), + 255, 255, 255 + ) + ) + fun ChatItem.toChatMessageUiState( context: Context, appearanceSettings: AppearanceSettings, @@ -41,31 +69,34 @@ object ChatMessageMapper { isAlternateBackground: Boolean, ): ChatMessageUiState { val textAlpha = when (importance) { - ChatImportance.SYSTEM -> 0.75f + ChatImportance.SYSTEM -> 0.75f ChatImportance.DELETED -> 0.5f ChatImportance.REGULAR -> 1f } return when (val msg = message) { - is SystemMessage -> msg.toSystemMessageUi( + is SystemMessage -> msg.toSystemMessageUi( context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, textAlpha = textAlpha ) - is NoticeMessage -> msg.toNoticeMessageUi( + + is NoticeMessage -> msg.toNoticeMessageUi( context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, textAlpha = textAlpha ) - is UserNoticeMessage -> msg.toUserNoticeMessageUi( + + is UserNoticeMessage -> msg.toUserNoticeMessageUi( context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, textAlpha = textAlpha ) - is PrivMessage -> msg.toPrivMessageUi( + + is PrivMessage -> msg.toPrivMessageUi( context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, @@ -74,18 +105,21 @@ object ChatMessageMapper { isInReplies = isInReplies, textAlpha = textAlpha ) - is ModerationMessage -> msg.toModerationMessageUi( + + is ModerationMessage -> msg.toModerationMessageUi( context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, textAlpha = textAlpha ) + is PointRedemptionMessage -> msg.toPointRedemptionMessageUi( context = context, chatSettings = chatSettings, textAlpha = textAlpha ) - is WhisperMessage -> msg.toWhisperMessageUi( + + is WhisperMessage -> msg.toWhisperMessageUi( context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -100,43 +134,46 @@ object ChatMessageMapper { isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.SystemMessageUi { - val backgroundColor = calculateCheckeredBackground(context, isAlternateBackground, false) + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) val timestamp = if (chatSettings.showTimestamps) { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) } else "" val message = when (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, type.status) - is SystemMessageType.ChannelFFZEmotesFailed -> context.getString(R.string.system_message_ffz_emotes_failed, type.status) - is SystemMessageType.ChannelSevenTVEmotesFailed -> context.getString(R.string.system_message_7tv_emotes_failed, type.status) - is SystemMessageType.Custom -> type.message - is SystemMessageType.MessageHistoryUnavailable -> when (type.status) { + 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, type.status) + is SystemMessageType.ChannelFFZEmotesFailed -> context.getString(R.string.system_message_ffz_emotes_failed, type.status) + is SystemMessageType.ChannelSevenTVEmotesFailed -> context.getString(R.string.system_message_7tv_emotes_failed, type.status) + is SystemMessageType.Custom -> type.message + is SystemMessageType.MessageHistoryUnavailable -> when (type.status) { null -> context.getString(R.string.system_message_history_unavailable) else -> context.getString(R.string.system_message_history_unavailable_detailed, type.status) } - is SystemMessageType.ChannelSevenTVEmoteAdded -> context.getString(R.string.system_message_7tv_emote_added, type.actorName, type.emoteName) - is SystemMessageType.ChannelSevenTVEmoteRemoved -> context.getString(R.string.system_message_7tv_emote_removed, type.actorName, type.emoteName) - is SystemMessageType.ChannelSevenTVEmoteRenamed -> context.getString( + + is SystemMessageType.ChannelSevenTVEmoteAdded -> context.getString(R.string.system_message_7tv_emote_added, type.actorName, type.emoteName) + is SystemMessageType.ChannelSevenTVEmoteRemoved -> context.getString(R.string.system_message_7tv_emote_removed, type.actorName, type.emoteName) + is SystemMessageType.ChannelSevenTVEmoteRenamed -> context.getString( R.string.system_message_7tv_emote_renamed, type.actorName, type.oldEmoteName, type.emoteName ) + is SystemMessageType.ChannelSevenTVEmoteSetChanged -> context.getString(R.string.system_message_7tv_emote_set_changed, type.actorName, type.newEmoteSetName) } return ChatMessageUiState.SystemMessageUi( id = id, timestamp = timestamp, - backgroundColor = backgroundColor, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, message = message ) @@ -148,7 +185,7 @@ object ChatMessageMapper { isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.NoticeMessageUi { - val backgroundColor = calculateCheckeredBackground(context, isAlternateBackground, false) + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) val timestamp = if (chatSettings.showTimestamps) { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) } else "" @@ -156,7 +193,8 @@ object ChatMessageMapper { return ChatMessageUiState.NoticeMessageUi( id = id, timestamp = timestamp, - backgroundColor = backgroundColor, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, message = message ) @@ -168,13 +206,13 @@ object ChatMessageMapper { isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.UserNoticeMessageUi { - val shouldHighlight = highlights.any { - it.type == com.flxrs.dankchat.data.twitch.message.HighlightType.Subscription || - it.type == com.flxrs.dankchat.data.twitch.message.HighlightType.Announcement + val shouldHighlight = highlights.any { + it.type == HighlightType.Subscription || + it.type == HighlightType.Announcement } - val backgroundColor = when { - shouldHighlight -> ContextCompat.getColor(context, R.color.color_sub_highlight) - else -> calculateCheckeredBackground(context, isAlternateBackground, false) + val backgroundColors = when { + shouldHighlight -> getHighlightColors(HighlightType.Subscription) + else -> calculateCheckeredBackgroundColors(isAlternateBackground, false) } val timestamp = if (chatSettings.showTimestamps) { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) @@ -183,7 +221,8 @@ object ChatMessageMapper { return ChatMessageUiState.UserNoticeMessageUi( id = id, timestamp = timestamp, - backgroundColor = backgroundColor, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, message = message, shouldHighlight = shouldHighlight @@ -196,7 +235,7 @@ object ChatMessageMapper { isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.ModerationMessageUi { - val backgroundColor = calculateCheckeredBackground(context, isAlternateBackground, false) + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) val timestamp = if (chatSettings.showTimestamps) { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) } else "" @@ -204,9 +243,10 @@ object ChatMessageMapper { return ChatMessageUiState.ModerationMessageUi( id = id, timestamp = timestamp, - backgroundColor = backgroundColor, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, - message = "" // TODO: Implement getSystemMessage + message = "" // Moderation messages don't need text - they're action notifications ) } @@ -219,10 +259,10 @@ object ChatMessageMapper { isInReplies: Boolean, textAlpha: Float, ): ChatMessageUiState.PrivMessageUi { - val bgColor = when { - timedOut && !chatSettings.showTimedOutMessages -> Color.TRANSPARENT - highlights.isNotEmpty() -> highlights.toBackgroundColor(context) - else -> calculateCheckeredBackground(context, isAlternateBackground, true) + val backgroundColors = when { + timedOut && !chatSettings.showTimedOutMessages -> BackgroundColors(Color.Transparent, Color.Transparent) + highlights.isNotEmpty() -> highlights.toBackgroundColors() + else -> calculateCheckeredBackgroundColors(isAlternateBackground, true) } val timestamp = if (chatSettings.showTimestamps) { @@ -230,10 +270,10 @@ object ChatMessageMapper { } else "" val nameText = when { - !chatSettings.showUsernames -> "" - isAction -> "$aliasOrFormattedName " + !chatSettings.showUsernames -> "" + isAction -> "$aliasOrFormattedName " aliasOrFormattedName.isBlank() -> "" - else -> "$aliasOrFormattedName: " + else -> "$aliasOrFormattedName: " } val allowedBadges = badges.filter { it.type in chatSettings.visibleBadgeTypes } @@ -249,16 +289,16 @@ object ChatMessageMapper { // 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.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.GlobalBTTVEmote -> true // Assume third-party can be animated is ChatMessageEmoteType.ChannelSevenTVEmote, is ChatMessageEmoteType.GlobalSevenTVEmote -> true } } - + EmoteUi( code = emoteGroup.first().code, urls = emoteGroup.map { it.url }, @@ -285,10 +325,15 @@ object ChatMessageMapper { append(message) } + // Compute name colors for both light and dark backgrounds + val lightNameColorInt = customOrUserColorOn(backgroundColors.light.toArgb()) + val darkNameColorInt = customOrUserColorOn(backgroundColors.dark.toArgb()) + return ChatMessageUiState.PrivMessageUi( id = id, timestamp = timestamp, - backgroundColor = bgColor, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, enableRipple = true, channel = channel, @@ -296,7 +341,8 @@ object ChatMessageMapper { userName = name, displayName = displayName, badges = badgeUis, - nameColor = customOrUserColorOn(bgColor), + lightNameColor = Color(lightNameColorInt), + darkNameColor = Color(darkNameColorInt), nameText = nameText, message = message, emotes = emoteUis, @@ -311,7 +357,7 @@ object ChatMessageMapper { chatSettings: ChatSettings, textAlpha: Float, ): ChatMessageUiState.PointRedemptionMessageUi { - val backgroundColor = ContextCompat.getColor(context, R.color.color_redemption_highlight) + val backgroundColors = getHighlightColors(HighlightType.ChannelPointRedemption) val timestamp = if (chatSettings.showTimestamps) { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) } else "" @@ -321,7 +367,8 @@ object ChatMessageMapper { return ChatMessageUiState.PointRedemptionMessageUi( id = id, timestamp = timestamp, - backgroundColor = backgroundColor, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, nameText = nameText, title = title, @@ -337,7 +384,7 @@ object ChatMessageMapper { isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.WhisperMessageUi { - val backgroundColor = calculateCheckeredBackground(context, isAlternateBackground, true) + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, true) val timestamp = if (chatSettings.showTimestamps) { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) } else "" @@ -355,16 +402,17 @@ object ChatMessageMapper { // Check if any emote in the group is animated val hasAnimated = emoteGroup.any { emote -> when (emote.type) { - is ChatMessageEmoteType.TwitchEmote -> false + is ChatMessageEmoteType.TwitchEmote -> false is ChatMessageEmoteType.ChannelFFZEmote, is ChatMessageEmoteType.GlobalFFZEmote, is ChatMessageEmoteType.ChannelBTTVEmote, - is ChatMessageEmoteType.GlobalBTTVEmote -> true + is ChatMessageEmoteType.GlobalBTTVEmote -> true + is ChatMessageEmoteType.ChannelSevenTVEmote, is ChatMessageEmoteType.GlobalSevenTVEmote -> true } } - + EmoteUi( code = emoteGroup.first().code, urls = emoteGroup.map { it.url }, @@ -384,18 +432,27 @@ object ChatMessageMapper { append(message) } + // Compute colors for both light and dark backgrounds + val lightSenderColorInt = senderColorOnBackground(backgroundColors.light.toArgb()) + val darkSenderColorInt = senderColorOnBackground(backgroundColors.dark.toArgb()) + val lightRecipientColorInt = recipientColorOnBackground(backgroundColors.light.toArgb()) + val darkRecipientColorInt = recipientColorOnBackground(backgroundColors.dark.toArgb()) + return ChatMessageUiState.WhisperMessageUi( id = id, timestamp = timestamp, - backgroundColor = backgroundColor, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, enableRipple = true, userId = userId ?: error("Whisper must have userId"), userName = name, displayName = displayName, badges = badgeUis, - senderColor = senderColorOnBackground(backgroundColor), - recipientColor = recipientColorOnBackground(backgroundColor), + lightSenderColor = Color(lightSenderColorInt), + darkSenderColor = Color(darkSenderColorInt), + lightRecipientColor = Color(lightRecipientColorInt), + darkRecipientColor = Color(darkRecipientColorInt), senderName = senderAliasOrFormattedName, recipientName = recipientAliasOrFormattedName, message = message, @@ -404,42 +461,55 @@ object ChatMessageMapper { ) } - private fun calculateCheckeredBackground( - context: Context, + data class BackgroundColors(val light: Color, val dark: Color) + + private fun calculateCheckeredBackgroundColors( isAlternateBackground: Boolean, - enableCheckered: Boolean, // Will be controlled by settings - ): Int { - return when { - enableCheckered && isAlternateBackground -> { - // Manual calculation since we don't have a View - val backgroundColor = android.graphics.Color.TRANSPARENT - val surfaceInverse = ContextCompat.getColor(context, android.R.color.white) - // Use alpha blending for checkered effect - android.graphics.Color.argb( - (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), - android.graphics.Color.red(surfaceInverse), - android.graphics.Color.green(surfaceInverse), - android.graphics.Color.blue(surfaceInverse) - ) - } - else -> ContextCompat.getColor(context, android.R.color.transparent) + enableCheckered: Boolean, + ): BackgroundColors { + return if (enableCheckered && isAlternateBackground) { + BackgroundColors(CHECKERED_LIGHT, CHECKERED_DARK) + } else { + BackgroundColors(Color.Transparent, Color.Transparent) } } - @ColorInt - private fun Set.toBackgroundColor(context: Context): Int { - val highlight = this.maxByOrNull { it.type.priority.value } - ?: return ContextCompat.getColor(context, android.R.color.transparent) - return when (highlight.type) { - com.flxrs.dankchat.data.twitch.message.HighlightType.Subscription, - com.flxrs.dankchat.data.twitch.message.HighlightType.Announcement -> ContextCompat.getColor(context, R.color.color_sub_highlight) - com.flxrs.dankchat.data.twitch.message.HighlightType.ChannelPointRedemption -> ContextCompat.getColor(context, R.color.color_redemption_highlight) - com.flxrs.dankchat.data.twitch.message.HighlightType.ElevatedMessage -> ContextCompat.getColor(context, R.color.color_elevated_message_highlight) - com.flxrs.dankchat.data.twitch.message.HighlightType.FirstMessage -> ContextCompat.getColor(context, R.color.color_first_message_highlight) - com.flxrs.dankchat.data.twitch.message.HighlightType.Username, - com.flxrs.dankchat.data.twitch.message.HighlightType.Custom, - com.flxrs.dankchat.data.twitch.message.HighlightType.Reply, - com.flxrs.dankchat.data.twitch.message.HighlightType.Notification -> ContextCompat.getColor(context, R.color.color_mention_highlight) + private fun getHighlightColors(type: HighlightType): BackgroundColors { + return when (type) { + HighlightType.Subscription, + HighlightType.Announcement -> BackgroundColors( + light = COLOR_SUB_HIGHLIGHT_LIGHT, + dark = COLOR_SUB_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.Notification -> BackgroundColors( + light = COLOR_MENTION_HIGHLIGHT_LIGHT, + dark = COLOR_MENTION_HIGHLIGHT_DARK, + ) } } -} \ No newline at end of file + + private fun Set.toBackgroundColors(): BackgroundColors { + val highlight = this.maxByOrNull { it.type.priority.value } + ?: return BackgroundColors(Color.Transparent, Color.Transparent) + return getHighlightColors(highlight.type) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt index e57b6a1fc..d8a405fa9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt @@ -5,11 +5,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.MaterialTheme 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.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -17,7 +17,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.em /** * Renders a chat message text with support for: @@ -25,20 +25,27 @@ import androidx.compose.ui.unit.sp * - Username colors * - Emotes and badges (via InlineTextContent) * - Clickable spans (usernames, links, emotes) + * + * NOTE: fontSize should come from appearanceSettings.fontSize, not be hardcoded + * NOTE: nameColor should come from the message's nameColor, not be hardcoded */ @Composable fun ChatMessageText( text: String, + fontSize: TextUnit, modifier: Modifier = Modifier, - fontSize: TextUnit = 14.sp, - textColor: Color = Color.White, + textColor: Color? = null, timestamp: String? = null, nameText: String? = null, - nameColor: Color = Color.Gray, + nameColor: Color? = null, isAction: Boolean = false, inlineContent: Map = emptyMap(), ) { - val annotatedString = remember(text, timestamp, nameText, nameColor, isAction, textColor) { + val timestampColor = MaterialTheme.colorScheme.onSurface + val defaultTextColor = textColor ?: MaterialTheme.colorScheme.onSurface + val defaultNameColor = nameColor ?: MaterialTheme.colorScheme.onSurface + + val annotatedString = remember(text, timestamp, nameText, defaultNameColor, isAction, defaultTextColor, timestampColor, fontSize) { buildAnnotatedString { // Add timestamp if present if (timestamp != null) { @@ -47,7 +54,8 @@ fun ChatMessageText( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = fontSize * 0.95f, - letterSpacing = 0.05.sp + color = timestampColor, + letterSpacing = (-0.03).em, ) ) { append(timestamp) @@ -59,7 +67,7 @@ fun ChatMessageText( if (nameText != null) { withStyle( SpanStyle( - color = nameColor, + color = defaultNameColor, fontWeight = FontWeight.Bold ) ) { @@ -75,7 +83,7 @@ fun ChatMessageText( // Add message text withStyle( SpanStyle( - color = if (isAction) nameColor else textColor + color = if (isAction) defaultNameColor else defaultTextColor ) ) { append(text) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index b5d669aa1..3c99d5d14 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -1,7 +1,7 @@ package com.flxrs.dankchat.chat.compose -import androidx.annotation.ColorInt 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 @@ -17,7 +17,8 @@ import com.flxrs.dankchat.data.twitch.message.MessageThreadHeader sealed interface ChatMessageUiState { val id: String val timestamp: String - val backgroundColor: Int + val lightBackgroundColor: Color + val darkBackgroundColor: Color val textAlpha: Float val enableRipple: Boolean @@ -28,7 +29,8 @@ sealed interface ChatMessageUiState { data class PrivMessageUi( override val id: String, override val timestamp: String, - @ColorInt override val backgroundColor: Int, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean, val channel: UserName, @@ -36,7 +38,8 @@ sealed interface ChatMessageUiState { val userName: UserName, val displayName: DisplayName, val badges: List, - @ColorInt val nameColor: Int, + val lightNameColor: Color, + val darkNameColor: Color, val nameText: String, val message: String, val emotes: List, @@ -52,7 +55,8 @@ sealed interface ChatMessageUiState { data class SystemMessageUi( override val id: String, override val timestamp: String, - @ColorInt override val backgroundColor: Int, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, val message: String, @@ -65,7 +69,8 @@ sealed interface ChatMessageUiState { data class NoticeMessageUi( override val id: String, override val timestamp: String, - @ColorInt override val backgroundColor: Int, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, val message: String, @@ -78,7 +83,8 @@ sealed interface ChatMessageUiState { data class UserNoticeMessageUi( override val id: String, override val timestamp: String, - @ColorInt override val backgroundColor: Int, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, val message: String, @@ -92,7 +98,8 @@ sealed interface ChatMessageUiState { data class ModerationMessageUi( override val id: String, override val timestamp: String, - @ColorInt override val backgroundColor: Int, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, val message: String, @@ -105,7 +112,8 @@ sealed interface ChatMessageUiState { data class PointRedemptionMessageUi( override val id: String, override val timestamp: String, - @ColorInt override val backgroundColor: Int, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, val nameText: String?, @@ -122,15 +130,18 @@ sealed interface ChatMessageUiState { data class WhisperMessageUi( override val id: String, override val timestamp: String, - @ColorInt override val backgroundColor: Int, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean, val userId: UserId, val userName: UserName, val displayName: DisplayName, val badges: List, - @ColorInt val senderColor: Int, - @ColorInt val recipientColor: Int, + val lightSenderColor: Color, + val darkSenderColor: Color, + val lightRecipientColor: Color, + val darkRecipientColor: Color, val senderName: String, val recipientName: String, val message: String, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 67bd3f23b..a334d4d6d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -4,18 +4,24 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +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.unit.dp @@ -26,6 +32,7 @@ import com.flxrs.dankchat.chat.compose.messages.PrivMessageComposable import com.flxrs.dankchat.chat.compose.messages.SystemMessageComposable import com.flxrs.dankchat.chat.compose.messages.UserNoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.WhisperMessageComposable +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** * Main composable for rendering chat messages in a scrollable list. @@ -39,65 +46,103 @@ import com.flxrs.dankchat.chat.compose.messages.WhisperMessageComposable @Composable fun ChatScreen( messages: List, - fontSize: Float = 14f, + fontSize: Float, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, modifier: Modifier = Modifier, - 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 = {}, + showChannelPrefix: Boolean = false, + showLineSeparator: Boolean = false, + animateGifs: Boolean = true, + onEmoteClick: (emotes: List) -> Unit = {}, onReplyClick: (rootMessageId: String) -> Unit = {}, ) { val listState = rememberLazyListState() - - // Track if user is at bottom + val scope = rememberCoroutineScope() + + // Track if we should auto-scroll to bottom (sticky state) + // Use rememberSaveable to survive configuration changes (like theme switches) + var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } + + // Detect if we're showing the newest messages val isAtBottom by remember { derivedStateOf { - val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() - lastVisibleItem?.index == listState.layoutInfo.totalItemsCount - 1 + val firstVisibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() + firstVisibleItem?.index == 0 } } - // Auto-scroll to bottom when new messages arrive and user is already at bottom - LaunchedEffect(messages.size) { - if (isAtBottom && messages.isNotEmpty()) { - listState.scrollToItem(messages.size - 1) + // Disable auto-scroll when user scrolls forward (up in the chat) + LaunchedEffect(listState.isScrollInProgress) { + if (listState.lastScrolledForward && shouldAutoScroll) { + shouldAutoScroll = false } } - Box(modifier = modifier.fillMaxSize()) { - LazyColumn( - state = listState, - reverseLayout = true, - modifier = Modifier.fillMaxSize() - ) { - items( - items = messages.asReversed(), - key = { message -> message.id } - ) { message -> - ChatMessageItem( - message = message, - fontSize = fontSize, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick, - ) - } + // Auto-scroll when new messages arrive or when re-enabled + LaunchedEffect(shouldAutoScroll, messages) { + if (shouldAutoScroll) { + listState.scrollToItem(0) } + } - // Scroll to bottom FAB - if (!isAtBottom && messages.isNotEmpty()) { - FloatingActionButton( - onClick = { - // TODO: Smooth scroll - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp) + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier.fillMaxSize() ) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to bottom" - ) + items( + items = messages.asReversed(), + 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.PrivMessageUi -> "privmsg" + is ChatMessageUiState.WhisperMessageUi -> "whisper" + is ChatMessageUiState.PointRedemptionMessageUi -> "redemption" + } + } + ) { message -> + ChatMessageItem( + message = message, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick, + ) + + // Add divider after each message if enabled + if (showLineSeparator) { + HorizontalDivider() + } + } + } + + // Scroll to bottom FAB (show when not at bottom) + if (!isAtBottom && messages.isNotEmpty()) { + FloatingActionButton( + onClick = { + shouldAutoScroll = true + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom" + ) + } } } } @@ -110,51 +155,61 @@ fun ChatScreen( private fun ChatMessageItem( message: ChatMessageUiState, fontSize: Float, - onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + showChannelPrefix: Boolean, + animateGifs: Boolean, + 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, + onEmoteClick: (emotes: List) -> Unit, onReplyClick: (rootMessageId: String) -> Unit, ) { when (message) { - is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( + is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( message = message, fontSize = fontSize ) - is ChatMessageUiState.NoticeMessageUi -> NoticeMessageComposable( + + is ChatMessageUiState.NoticeMessageUi -> NoticeMessageComposable( message = message, fontSize = fontSize ) - is ChatMessageUiState.UserNoticeMessageUi -> UserNoticeMessageComposable( + + is ChatMessageUiState.UserNoticeMessageUi -> UserNoticeMessageComposable( message = message, fontSize = fontSize ) - is ChatMessageUiState.ModerationMessageUi -> ModerationMessageComposable( + + is ChatMessageUiState.ModerationMessageUi -> ModerationMessageComposable( message = message, fontSize = fontSize ) - is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( + + is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( message = message, fontSize = fontSize, - onUserClick = { userId, userName, displayName, channel, isLongPress -> - onUserClick(userId, userName, displayName, channel, emptyList(), isLongPress) - }, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, onReplyClick = onReplyClick ) + is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( message = message, fontSize = fontSize ) - is ChatMessageUiState.WhisperMessageUi -> WhisperMessageComposable( + + is ChatMessageUiState.WhisperMessageUi -> WhisperMessageComposable( message = message, fontSize = fontSize, - onUserClick = { userId, userName, displayName, isLongPress -> - onUserClick(userId, userName, displayName, null, emptyList(), isLongPress) + animateGifs = animateGifs, + onUserClick = { userId, userName, displayName, badges, isLongPress -> + onUserClick(userId, userName, displayName, null, badges, isLongPress) }, onMessageLongClick = { messageId, fullMessage -> onMessageLongClick(messageId, null, fullMessage) - } + }, + onEmoteClick = onEmoteClick ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt new file mode 100644 index 000000000..f24f4eff7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt @@ -0,0 +1,131 @@ +package com.flxrs.dankchat.chat.compose + +import android.graphics.drawable.Animatable +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 coil3.DrawableImage +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.flxrs.dankchat.utils.extensions.setRunning + +/** + * Coordinates emote loading and animation synchronization across the entire chat. + * + * Based on the old ChatAdapter/EmoteRepository approach: + * - Uses LruCache to cache drawables (bounded memory, unlike ConcurrentHashMap) + * - Shares Drawable instances across all usages of the same emote + * - This keeps animated GIF frame counters synchronized naturally + * - No mutex needed - Coil handles concurrent requests internally + * - Emote animation controlled via setRunning() based on animateGifs setting + * + * Same pattern as: + * - EmoteRepository.badgeCache: LruCache(64) + * - EmoteRepository.layerCache: LruCache(256) + */ +@Stable +class EmoteAnimationCoordinator( + val imageLoader: ImageLoader, + private val platformContext: PlatformContext, +) { + // LruCache for single emote drawables (like badgeCache in EmoteRepository) + private val emoteCache = LruCache(256) + + // LruCache for stacked emote drawables (like layerCache in EmoteRepository) + private val layerCache = LruCache(128) + + /** + * Get or load an emote drawable. + * + * Returns cached drawable if available, otherwise loads and caches it. + * Sharing the same Drawable instance keeps animations synchronized. + */ + suspend fun getOrLoadEmote(url: String, animateGifs: Boolean): Drawable? { + // Fast path: already cached + emoteCache.get(url)?.let { cached -> + // Control animation based on setting + if (cached is Animatable) { + cached.setRunning(animateGifs) + } + return cached + } + + // Load the emote via Coil (Coil handles concurrent requests internally) + return try { + val request = ImageRequest.Builder(platformContext) + .data(url) + .build() + + val result = imageLoader.execute(request) + if (result is SuccessResult) { + val image = result.image + if (image is DrawableImage) { + val drawable = image.drawable + // Cache it for reuse + emoteCache.put(url, drawable) + // Control animation + if (drawable is Animatable) { + drawable.setRunning(animateGifs) + } + drawable + } else { + null + } + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Check if an emote is already cached. + */ + fun getCached(url: String): Drawable? = emoteCache.get(url) + + /** + * Put a drawable in the cache (used by AsyncImage onSuccess callback). + */ + fun putInCache(url: String, drawable: Drawable) { + emoteCache.put(url, drawable) + } + + /** + * Get a cached LayerDrawable for stacked emotes. + */ + fun getLayerCached(cacheKey: String): LayerDrawable? = layerCache.get(cacheKey) + + /** + * Put a LayerDrawable in the cache for stacked emotes. + */ + fun putLayerInCache(cacheKey: String, layerDrawable: LayerDrawable) { + layerCache.put(cacheKey, layerDrawable) + } + + /** + * Clear all caches. + */ + fun clear() { + emoteCache.evictAll() + layerCache.evictAll() + } +} + +/** + * Provides a singleton EmoteAnimationCoordinator for the composition. + * This ensures all messages share the same coordinator instance. + */ +@Composable +fun rememberEmoteAnimationCoordinator(imageLoader: ImageLoader): EmoteAnimationCoordinator { + val context = LocalPlatformContext.current + return remember(imageLoader) { + EmoteAnimationCoordinator(imageLoader, context) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt new file mode 100644 index 000000000..fa3fd2db3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt @@ -0,0 +1,96 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import kotlin.math.roundToInt + +/** + * Emote scaling utilities that match the original ChatAdapter logic EXACTLY. + * + * Old ChatAdapter constants: + * - BASE_HEIGHT_CONSTANT = 1.173 + * - SCALE_FACTOR_CONSTANT = 1.5 / 112 + * - baseHeight = textSize * BASE_HEIGHT_CONSTANT + * - scaleFactor = baseHeight * SCALE_FACTOR_CONSTANT + * + * This ensures 100% visual parity with the old TextView-based rendering. + */ +object EmoteScaling { + private const val BASE_HEIGHT_CONSTANT = 1.173 + private const val SCALE_FACTOR_CONSTANT = 1.5 / 112 + + /** + * Calculate base emote height from font size. + * This matches the line height of text. + */ + fun getBaseHeight(fontSizeSp: Float): Dp { + return (fontSizeSp * BASE_HEIGHT_CONSTANT).dp + } + + /** + * Calculate scale factor exactly as ChatAdapter did from fontSize in SP. + */ + private fun getScaleFactor(fontSizeSp: Float): Double { + val baseHeight = fontSizeSp * BASE_HEIGHT_CONSTANT + return baseHeight * SCALE_FACTOR_CONSTANT + } + + /** + * Calculate scale factor from base height in pixels. + */ + fun getScaleFactor(baseHeightPx: Int): Double { + return baseHeightPx * SCALE_FACTOR_CONSTANT + } + + /** + * Calculate scaled emote dimensions matching old ChatAdapter.transformEmoteDrawable() EXACTLY. + * + * Old logic: + * 1. ratio = intrinsicWidth / intrinsicHeight + * 2. height = special handling for Twitch emotes, else intrinsicHeight * scale + * 3. width = height * ratio + * 4. scaledWidth = width * emote.scale + * 5. scaledHeight = height * emote.scale + * + * Returns pixel dimensions. + * + * @param intrinsicWidth Original emote width in pixels + * @param intrinsicHeight Original emote height in pixels + * @param emote The emote with scale factor and type info + * @param baseHeightPx Base height in pixels (line height) + * @return Pair of (widthPx, heightPx) in pixels + */ + fun calculateEmoteDimensionsPx( + intrinsicWidth: Int, + intrinsicHeight: Int, + emote: ChatMessageEmote, + baseHeightPx: Int + ): Pair { + val scale = baseHeightPx * SCALE_FACTOR_CONSTANT + + val ratio = intrinsicWidth / intrinsicHeight.toFloat() + + // Match ChatAdapter height calculation exactly + 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() + + // Apply individual emote scale + val scaledWidth = (width.toFloat() * emote.scale).roundToInt() + val scaledHeight = (height.toFloat() * emote.scale).roundToInt() + + return Pair(scaledWidth, scaledHeight) + } + + /** + * Calculate badge dimensions. + * Badges are always square at the base height. + */ + fun getBadgeSize(fontSizeSp: Float): Dp { + return getBaseHeight(fontSizeSp) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt new file mode 100644 index 000000000..cd70e3afa --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt @@ -0,0 +1,241 @@ +package com.flxrs.dankchat.chat.compose + +import android.graphics.Rect +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.widget.ImageView +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import coil3.asDrawable +import coil3.compose.LocalPlatformContext +import coil3.compose.rememberAsyncImagePainter +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.size.Size +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.utils.extensions.forEachLayer +import com.flxrs.dankchat.utils.extensions.setRunning +import kotlin.math.roundToInt + +/** + * Renders stacked emotes exactly like old ChatAdapter using LayerDrawable. + * + * Key differences from previous approaches: + * - Creates actual LayerDrawable like ChatAdapter did + * - Uses LruCache for LayerDrawables (not individual drawables) + * - Uses AndroidView with ImageView to render the LayerDrawable + * - NO ContentScale, NO Modifier.size on Image - drawable bounds handle everything + */ +@Composable +fun StackedEmote( + emote: EmoteUi, + fontSize: Float, + emoteCoordinator: EmoteAnimationCoordinator, + modifier: Modifier = Modifier, + animateGifs: Boolean = true, + onClick: () -> Unit = {}, +) { + val context = LocalPlatformContext.current + val density = LocalDensity.current + val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + val scaleFactor = EmoteScaling.getScaleFactor(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, + modifier = modifier, + onClick = onClick + ) + return + } + + // For stacked emotes, create cache key matching old implementation + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + + // 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 (e: Exception) { + null + } + }.toTypedArray() + + if (drawables.isNotEmpty()) { + // Create LayerDrawable exactly like old implementation + val layerDrawable = drawables.toLayerDrawable(scaleFactor, emote.emotes) + emoteCoordinator.putLayerInCache(cacheKey, layerDrawable) + 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) } + } + + // Render LayerDrawable if available using rememberAsyncImagePainter + layerDrawableState.value?.let { layerDrawable -> + val widthDp = with(density) { layerDrawable.bounds.width().toDp() } + val heightDp = with(density) { layerDrawable.bounds.height().toDp() } + + // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model + val painter = rememberAsyncImagePainter(model = layerDrawable) + + Image( + painter = painter, + contentDescription = null, + modifier = modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() } + ) + } +} + +/** + * Renders a single emote as a Drawable, matching old ChatAdapter behavior. + */ +@Composable +private fun SingleEmoteDrawable( + url: String, + chatEmote: ChatMessageEmote, + scaleFactor: Double, + emoteCoordinator: EmoteAnimationCoordinator, + animateGifs: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + val context = LocalPlatformContext.current + val density = LocalDensity.current + + // 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) + value = transformed + } + } catch (e: Exception) { + // Ignore errors + } + } + } + + // Update animation state when setting changes + LaunchedEffect(animateGifs, drawableState.value) { + if (drawableState.value is Animatable) { + (drawableState.value as Animatable).setRunning(animateGifs) + } + } + + // Render drawable if available + drawableState.value?.let { drawable -> + val widthDp = with(density) { drawable.bounds.width().toDp() } + val heightDp = with(density) { drawable.bounds.height().toDp() } + + // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model + val painter = rememberAsyncImagePainter(model = drawable) + + Image( + painter = painter, + contentDescription = null, + modifier = modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() } + ) + } +} + +/** + * Transform emote drawable exactly like old ChatAdapter.transformEmoteDrawable(). + * Phase 1: Individual scaling without maxWidth/maxHeight. + */ +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 +} + +/** + * Create LayerDrawable from array of drawables exactly like old ChatAdapter.toLayerDrawable(). + */ +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/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt new file mode 100644 index 000000000..02a10d591 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -0,0 +1,171 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +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.unit.Constraints +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch + +/** + * Data class to hold measured emote dimensions + */ +data class EmoteDimensions( + val id: String, + val widthPx: Int, + val heightPx: Int +) + +/** + * Renders text with inline images (badges, emotes) using SubcomposeLayout. + * + * This solves the fundamental problem with InlineTextContent: we need to know + * the size of images before creating Placeholder objects, but images load asynchronously. + * + * SubcomposeLayout allows us to: + * 1. First measure all inline images to get their actual dimensions + * 2. Create InlineTextContent with correct Placeholder sizes + * 3. Finally compose the text with properly sized placeholders + * + * This maintains natural text flow (like TextView) while supporting variable-sized + * inline content (like ImageSpans with different drawable sizes). + * + * @param text The AnnotatedString with annotations marking where inline content goes + * @param inlineContentProviders Map of content IDs to composables that will be measured + * @param modifier Modifier for the text + * @param onTextClick Callback for click events with offset position + * @param onTextLongClick Callback for long-click events with offset position + * @param interactionSource Optional interaction source for ripple effects + */ +@Composable +fun TextWithMeasuredInlineContent( + text: AnnotatedString, + inlineContentProviders: Map Unit>, + modifier: Modifier = Modifier, + onTextClick: ((Int) -> Unit)? = null, + onTextLongClick: ((Int) -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + + SubcomposeLayout(modifier = modifier) { constraints -> + // Phase 1: Measure all inline content to get actual dimensions + val measuredDimensions = mutableMapOf() + + inlineContentProviders.forEach { (id, provider) -> + val measurables = subcompose("measure_$id", provider) + if (measurables.isNotEmpty()) { + // Measure with unbounded constraints to get natural size + val placeable = measurables.first().measure( + Constraints( + maxWidth = constraints.maxWidth, + maxHeight = Constraints.Infinity + ) + ) + measuredDimensions[id] = EmoteDimensions( + id = id, + widthPx = placeable.width, + heightPx = placeable.height + ) + } + } + + // Phase 2: Create InlineTextContent with measured dimensions + val inlineContent = measuredDimensions.mapValues { (id, dimensions) -> + InlineTextContent( + placeholder = Placeholder( + width = with(density) { dimensions.widthPx.toDp() }.value.sp, + height = with(density) { dimensions.heightPx.toDp() }.value.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + // Render the actual content (re-compose with same provider) + inlineContentProviders[id]?.invoke() + } + } + + // Phase 3: Compose the text with correct inline content + var textLayoutResult: androidx.compose.ui.text.TextLayoutResult? = null + + val textMeasurables = subcompose("text") { + BasicText( + text = text, + inlineContent = inlineContent, + modifier = Modifier.pointerInput(text, interactionSource) { + detectTapGestures( + onPress = { offset -> + // Emit press interaction for ripple effect + interactionSource?.let { source -> + val press = PressInteraction.Press(offset) + coroutineScope.launch { + source.emit(press) + tryAwaitRelease() + source.emit(PressInteraction.Release(press)) + } + } + }, + onTap = { offset -> + textLayoutResult?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + onTextClick?.invoke(position) + } + }, + onLongPress = { offset -> + textLayoutResult?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + onTextLongClick?.invoke(position) + } + } + ) + }, + onTextLayout = { layoutResult -> + textLayoutResult = layoutResult + } + ) + } + + if (textMeasurables.isEmpty()) { + return@SubcomposeLayout layout(0, 0) {} + } + + // Phase 4: Measure and layout the text + val textPlaceable = textMeasurables.first().measure(constraints) + + layout(textPlaceable.width, textPlaceable.height) { + textPlaceable.place(0, 0) + } + } +} + +/** + * Simpler version that just wraps BasicText with measured inline content. + * Use this when you already have the dimensions or don't need click handling. + */ +@Composable +fun MeasuredInlineText( + text: AnnotatedString, + inlineContent: Map, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + BasicText( + text = text, + inlineContent = inlineContent + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt deleted file mode 100644 index 5b76cdce3..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/ComplexMessageComposables.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.flxrs.dankchat.chat.compose.messages - -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -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.runtime.Composable -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.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.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage -import com.flxrs.dankchat.chat.compose.ChatMessageUiState - -/** - * Renders a whisper message (private message between users) - */ -@Composable -fun WhisperMessageComposable( - message: ChatMessageUiState.WhisperMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, - onUserClick: (userId: String?, userName: String, displayName: String, isLongPress: Boolean) -> Unit = { _, _, _, _ -> }, - onMessageLongClick: (messageId: String, fullMessage: String) -> Unit = { _, _ -> }, -) { - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .background(Color(message.backgroundColor)) - .combinedClickable( - onClick = {}, - onLongClick = { - onMessageLongClick(message.id, message.fullMessage) - } - ) - .padding(horizontal = 8.dp) - ) { - val annotatedString = remember(message) { - buildAnnotatedString { - // Timestamp - if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = (fontSize * 0.95f).sp, - letterSpacing = 0.05.sp - ) - ) { - append(message.timestamp) - } - append(" ") - } - - // Badges (simplified for now) - message.badges.forEach { _ -> - append("⠀ ") - } - - // Sender - withStyle( - SpanStyle( - color = Color(message.senderColor), - fontWeight = FontWeight.Bold - ) - ) { - append(message.senderName) - } - append(" -> ") - - // Recipient - withStyle( - SpanStyle( - color = Color(message.recipientColor), - fontWeight = FontWeight.Bold - ) - ) { - append(message.recipientName) - } - append(": ") - - // Message - withStyle(SpanStyle(color = Color.White.copy(alpha = message.textAlpha))) { - append(message.message) - } - } - } - - BasicText( - text = annotatedString, - modifier = Modifier.fillMaxWidth() - ) - } -} - -/** - * Renders a channel point redemption message - */ -@Composable -fun PointRedemptionMessageComposable( - message: ChatMessageUiState.PointRedemptionMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .background(Color(message.backgroundColor)) - .padding(horizontal = 8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - val annotatedString = remember(message) { - buildAnnotatedString { - // Timestamp - if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = (fontSize * 0.95f).sp, - letterSpacing = 0.05.sp - ) - ) { - append(message.timestamp) - } - append(" ") - } - - when { - message.requiresUserInput -> { - append("Redeemed ") - } - message.nameText != null -> { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(message.nameText) - } - append(" redeemed ") - } - } - - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(message.title) - } - append(" ") - } - } - - BasicText( - text = annotatedString, - modifier = Modifier.weight(1f) - ) - - AsyncImage( - model = message.rewardImageUrl, - contentDescription = message.title, - modifier = Modifier.size((fontSize * 1.5f).dp) - ) - - BasicText( - text = " ${message.cost}", - modifier = Modifier.padding(start = 4.dp) - ) - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt new file mode 100644 index 000000000..d240969a7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -0,0 +1,292 @@ +package com.flxrs.dankchat.chat.compose.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.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.graphics.Color +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.style.TextOverflow +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 coil3.compose.LocalPlatformContext +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.StackedEmote +import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent +import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor +import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote + +/** + * 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 + */ +@Composable +fun PrivMessageComposable( + message: ChatMessageUiState.PrivMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + showChannelPrefix: Boolean = false, + animateGifs: Boolean = true, + 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) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(backgroundColor) + .indication(interactionSource, ripple()) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + // Reply thread header + if (message.thread != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onReplyClick(message.thread.rootId) } + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Reply, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + Text( + text = "Reply to @${message.thread.userName}: ${message.thread.message}", + fontSize = (fontSize * 0.9f).sp, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + // Main message + PrivMessageText( + message = message, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + interactionSource = interactionSource, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick + ) + } +} + +@Composable +private fun PrivMessageText( + message: ChatMessageUiState.PrivMessageUi, + fontSize: Float, + showChannelPrefix: Boolean, + animateGifs: Boolean, + interactionSource: MutableInteractionSource, + 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 imageLoader = coil3.ImageLoader.Builder(context).build() + val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) + val nameColor = rememberBackgroundColor(message.lightNameColor, message.darkNameColor) + + // Build annotated string with text content + val annotatedString = remember(message, defaultTextColor, nameColor, showChannelPrefix) { + 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( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + color = defaultTextColor, + letterSpacing = (-0.03).em + ) + ) { + append(message.timestamp) + append(" ") + } + } + + // 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 ?: ""}|${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.copy(alpha = message.textAlpha) + } + + withStyle(SpanStyle(color = textColor)) { + var currentPos = 0 + message.emotes.sortedBy { it.position.first }.forEach { emote -> + // Text before emote + if (currentPos < emote.position.first) { + append(message.message.substring(currentPos, emote.position.first)) + } + + // 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) { + append(message.message.substring(currentPos)) + } + } + } + } + + // Build inline content providers for SubcomposeLayout + val badgeSize = EmoteScaling.getBadgeSize(fontSize) + val inlineContentProviders: Map Unit> = remember(message.badges, message.emotes, fontSize) { + buildMap Unit> { + // Badge providers + message.badges.forEach { badge -> + put("BADGE_${badge.position}") { + coil3.compose.AsyncImage( + model = badge.url, + contentDescription = badge.badge.type.name, + modifier = Modifier.size(badgeSize) + ) + } + } + + // Emote providers + message.emotes.forEach { emote -> + put("EMOTE_${emote.code}") { + StackedEmote( + emote = emote, + fontSize = fontSize, + emoteCoordinator = emoteCoordinator, + animateGifs = animateGifs, + modifier = Modifier, + onClick = { onEmoteClick(emote.emotes) } + ) + } + } + } + } + + // Use SubcomposeLayout to measure inline content, then render text + TextWithMeasuredInlineContent( + text = annotatedString, + inlineContentProviders = inlineContentProviders, + modifier = Modifier.fillMaxWidth(), + interactionSource = interactionSource, + onTextClick = { offset -> + // Handle username clicks + annotatedString.getStringAnnotations("USER", offset, offset) + .firstOrNull()?.let { annotation -> + val parts = annotation.item.split("|") + if (parts.size == 4) { + val userId = parts[0].takeIf { it.isNotEmpty() } + val userName = parts[1] + val displayName = parts[2] + val channel = parts[3] + onUserClick(userId, userName, displayName, channel, message.badges, false) + } + } + }, + onTextLongClick = { offset -> + // Handle username long-press + val userAnnotation = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + if (userAnnotation != null) { + // Long-press on username + val parts = userAnnotation.item.split("|") + if (parts.size == 4) { + val userId = parts[0].takeIf { it.isNotEmpty() } + val userName = parts[1] + val displayName = parts[2] + val channel = parts[3] + onUserClick(userId, userName, displayName, channel, message.badges, true) + } + } else { + // Long-press on regular text (not username) - trigger message long-press + onMessageLongClick(message.id, message.channel.value, message.fullMessage) + } + } + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt deleted file mode 100644 index 9c28e58ae..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessageComposable.kt +++ /dev/null @@ -1,247 +0,0 @@ -package com.flxrs.dankchat.chat.compose.messages - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -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.BasicText -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Reply -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -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.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 com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.EmoteUi - -/** - * 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 - */ -@Composable -fun PrivMessageComposable( - message: ChatMessageUiState.PrivMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, - onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, isLongPress: Boolean) -> Unit = { _, _, _, _, _ -> }, - onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit = { _, _, _ -> }, - onEmoteClick: (emotes: List) -> Unit = {}, - onReplyClick: (rootMessageId: String) -> Unit = {}, -) { - Column( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .background(Color(message.backgroundColor)) - .combinedClickable( - onClick = {}, - onLongClick = { - onMessageLongClick(message.id, message.channel.value, message.fullMessage) - } - ) - .padding(horizontal = 8.dp) - ) { - // Reply thread header - if (message.thread != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onReplyClick(message.thread.rootId) } - .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Reply, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = Color.Gray - ) - Text( - text = "Reply to @${message.thread.userName}: ${message.thread.message}", - fontSize = (fontSize * 0.9f).sp, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - } - - // Main message - PrivMessageText( - message = message, - fontSize = fontSize, - onUserClick = onUserClick, - onEmoteClick = onEmoteClick - ) - } -} - -@Composable -private fun PrivMessageText( - message: ChatMessageUiState.PrivMessageUi, - fontSize: Float, - onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, isLongPress: Boolean) -> Unit, - onEmoteClick: (emotes: List) -> Unit, -) { - val annotatedString = remember(message) { - buildAnnotatedString { - // Timestamp - if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = (fontSize * 0.95f).sp, - letterSpacing = 0.05.sp - ) - ) { - append(message.timestamp) - } - append(" ") - } - - // Badges (placeholders) - message.badges.forEach { badge -> - pushStringAnnotation(tag = "BADGE", annotation = badge.position.toString()) - append("⠀") - pop() - append(" ") - } - - // Username - if (message.nameText.isNotEmpty()) { - pushStringAnnotation(tag = "USERNAME", annotation = message.userId?.value ?: "") - withStyle( - SpanStyle( - color = Color(message.nameColor), - fontWeight = FontWeight.Bold - ) - ) { - append(message.nameText.removeSuffix(": ").removeSuffix(" ")) - } - pop() - - if (message.isAction) { - append(" ") - } else { - append(": ") - } - } - - // Message text with emotes - val textColor = if (message.isAction) { - Color(message.nameColor) - } else { - Color.White.copy(alpha = message.textAlpha) - } - - withStyle(SpanStyle(color = textColor)) { - var currentPos = 0 - message.emotes.sortedBy { it.position.first }.forEach { emote -> - // Text before emote - if (currentPos < emote.position.first) { - append(message.message.substring(currentPos, emote.position.first)) - } - - // Emote placeholder - pushStringAnnotation(tag = "EMOTE", annotation = emote.code) - append("⠀") // Invisible space for emote - pop() - - currentPos = emote.position.last + 1 - } - - // Remaining text - if (currentPos < message.message.length) { - append(message.message.substring(currentPos)) - } - } - } - } - - // Inline content for badges and emotes - val inlineContent = remember(message.badges, message.emotes, fontSize) { - buildInlineContent(message.badges, message.emotes, fontSize, onEmoteClick) - } - - BasicText( - text = annotatedString, - modifier = Modifier.fillMaxWidth(), - inlineContent = inlineContent - ) -} - -private fun buildInlineContent( - badges: List, - emotes: List, - fontSize: Float, - onEmoteClick: (emotes: List) -> Unit, -): Map { - val content = mutableMapOf() - - // Badges - badges.forEach { badge -> - content["BADGE_${badge.position}"] = InlineTextContent( - placeholder = Placeholder( - width = (fontSize * 1.2f).sp, - height = fontSize.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter - ) - ) { - AsyncImage( - model = badge.url, - contentDescription = null, - modifier = Modifier.size((fontSize * 1.2f).dp) - ) - } - } - - // Emotes - emotes.forEach { emote -> - content["EMOTE_${emote.code}"] = InlineTextContent( - placeholder = Placeholder( - width = (fontSize * 2f).sp, - height = fontSize.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter - ) - ) { - AsyncImage( - model = emote.urls.firstOrNull(), - contentDescription = emote.code, - modifier = Modifier - .size((fontSize * 2f).dp) - .clickable { onEmoteClick(emote.emotes) } - ) - } - } - - return content -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt deleted file mode 100644 index 1f61d1c53..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SimpleMessageComposables.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.flxrs.dankchat.chat.compose.messages - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.sp -import com.flxrs.dankchat.chat.compose.ChatMessageText -import com.flxrs.dankchat.chat.compose.ChatMessageUiState - -/** - * Renders a system message (connected, disconnected, emote loading failures, etc.) - */ -@Composable -fun SystemMessageComposable( - message: ChatMessageUiState.SystemMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .background(Color(message.backgroundColor)) - ) { - ChatMessageText( - text = message.message, - timestamp = message.timestamp, - fontSize = fontSize.sp, - textColor = Color.White.copy(alpha = message.textAlpha), - ) - } -} - -/** - * Renders a notice message from Twitch - */ -@Composable -fun NoticeMessageComposable( - message: ChatMessageUiState.NoticeMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .background(Color(message.backgroundColor)) - ) { - ChatMessageText( - text = message.message, - timestamp = message.timestamp, - fontSize = fontSize.sp, - textColor = Color.White.copy(alpha = message.textAlpha), - ) - } -} - -/** - * Renders a user notice message (subscriptions, announcements, etc.) - */ -@Composable -fun UserNoticeMessageComposable( - message: ChatMessageUiState.UserNoticeMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .background(Color(message.backgroundColor)) - ) { - ChatMessageText( - text = message.message, - timestamp = message.timestamp, - fontSize = fontSize.sp, - textColor = Color.White.copy(alpha = message.textAlpha), - ) - } -} - -/** - * Renders a moderation message (timeouts, bans, deletions) - */ -@Composable -fun ModerationMessageComposable( - message: ChatMessageUiState.ModerationMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .background(Color(message.backgroundColor)) - ) { - ChatMessageText( - text = message.message, - timestamp = message.timestamp, - fontSize = fontSize.sp, - textColor = Color.White.copy(alpha = message.textAlpha), - ) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt new file mode 100644 index 000000000..05d8b15ad --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -0,0 +1,87 @@ +package com.flxrs.dankchat.chat.compose.messages + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.sp +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.messages.common.SimpleMessageContainer + +/** + * 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, + 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.) + */ +@Composable +fun UserNoticeMessageComposable( + message: ChatMessageUiState.UserNoticeMessageUi, + 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 moderation message (timeouts, bans, deletions) + */ +@Composable +fun ModerationMessageComposable( + message: ChatMessageUiState.ModerationMessageUi, + 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, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt new file mode 100644 index 000000000..14a84473b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -0,0 +1,326 @@ +package com.flxrs.dankchat.chat.compose.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.appendInlineContent +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.graphics.Color +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.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.StackedEmote +import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent +import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor +import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote + +/** + * Renders a whisper message (private message between users) + */ +@Composable +fun WhisperMessageComposable( + message: ChatMessageUiState.WhisperMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + animateGifs: Boolean = true, + onUserClick: (userId: String?, userName: String, displayName: String, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, fullMessage: String) -> Unit, + onEmoteClick: (emotes: List) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(backgroundColor) + .indication(interactionSource, ripple()) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + WhisperMessageText( + message = message, + fontSize = fontSize, + animateGifs = animateGifs, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick + ) + } +} + +@Composable +private fun WhisperMessageText( + message: ChatMessageUiState.WhisperMessageUi, + fontSize: Float, + animateGifs: Boolean, + 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 imageLoader = coil3.ImageLoader.Builder(context).build() + val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) + val senderColor = rememberBackgroundColor(message.lightSenderColor, message.darkSenderColor) + val recipientColor = rememberBackgroundColor(message.lightRecipientColor, message.darkRecipientColor) + + // Build annotated string with text content + val annotatedString = remember(message, defaultTextColor, senderColor, recipientColor) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + color = defaultTextColor, + letterSpacing = (-0.03).em + ) + ) { + append(message.timestamp) + append(" ") + } + } + + // Badges (using appendInlineContent for proper rendering) + message.badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") // Space between badges + } + + // Extra space after badges if any badges exist (before sender name) + if (message.badges.isNotEmpty()) { + // Already added spaces, no need for extra + } + + // 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() + } + append(" -> ") + + // Recipient + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = recipientColor + ) + ) { + append(message.recipientName) + } + append(": ") + + // Message text with emotes + withStyle(SpanStyle(color = defaultTextColor.copy(alpha = message.textAlpha))) { + var currentPos = 0 + message.emotes.sortedBy { it.position.first }.forEach { emote -> + // Text before emote + if (currentPos < emote.position.first) { + append(message.message.substring(currentPos, emote.position.first)) + } + + // 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) { + append(message.message.substring(currentPos)) + } + } + } + } + + // Build inline content providers for SubcomposeLayout + val badgeSize = EmoteScaling.getBadgeSize(fontSize) + val inlineContentProviders: Map Unit> = remember(message.badges, message.emotes, fontSize) { + buildMap Unit> { + // Badge providers + message.badges.forEach { badge -> + put("BADGE_${badge.position}") { + coil3.compose.AsyncImage( + model = badge.url, + contentDescription = badge.badge.type.name, + modifier = Modifier.size(badgeSize) + ) + } + } + + // Emote providers + message.emotes.forEach { emote -> + put("EMOTE_${emote.code}") { + StackedEmote( + emote = emote, + fontSize = fontSize, + emoteCoordinator = emoteCoordinator, + animateGifs = animateGifs, + modifier = Modifier, + onClick = { onEmoteClick(emote.emotes) } + ) + } + } + } + } + + // Use SubcomposeLayout to measure inline content, then render text + TextWithMeasuredInlineContent( + text = annotatedString, + inlineContentProviders = inlineContentProviders, + modifier = Modifier.fillMaxWidth(), + onTextClick = { offset -> + // Handle username clicks + annotatedString.getStringAnnotations("USER", offset, offset) + .firstOrNull()?.let { annotation -> + val parts = annotation.item.split("|") + if (parts.size == 3) { + val userId = parts[0].takeIf { it.isNotEmpty() } + val userName = parts[1] + val displayName = parts[2] + onUserClick(userId, userName, displayName, message.badges, false) + } + } + }, + onTextLongClick = { offset -> + // Handle username long-press + val userAnnotation = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + if (userAnnotation != null) { + // Long-press on username + val parts = userAnnotation.item.split("|") + if (parts.size == 3) { + val userId = parts[0].takeIf { it.isNotEmpty() } + val userName = parts[1] + val displayName = parts[2] + onUserClick(userId, userName, displayName, message.badges, true) + } + } else { + // Long-press on regular text (not username) - trigger message long-press + onMessageLongClick(message.id, message.fullMessage) + } + } + ) +} + +/** + * Renders a channel point redemption message + */ +@Composable +fun PointRedemptionMessageComposable( + message: ChatMessageUiState.PointRedemptionMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val timestampColor = rememberAdaptiveTextColor(backgroundColor) + + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(backgroundColor) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + val annotatedString = remember(message, timestampColor) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + color = timestampColor, + letterSpacing = (-0.03).em + ) + ) { + append(message.timestamp) + } + append(" ") + } + + when { + message.requiresUserInput -> { + append("Redeemed ") + } + + message.nameText != null -> { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(message.nameText) + } + append(" redeemed ") + } + } + + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(message.title) + } + append(" ") + } + } + + BasicText( + text = annotatedString, + modifier = Modifier.weight(1f) + ) + + AsyncImage( + model = message.rewardImageUrl, + contentDescription = message.title, + modifier = Modifier.size((fontSize * 1.5f).dp) + ) + + BasicText( + text = " ${message.cost}", + modifier = Modifier.padding(start = 4.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt new file mode 100644 index 000000000..21387cdad --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt @@ -0,0 +1,49 @@ +package com.flxrs.dankchat.chat.compose.messages.common + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.EmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.EmoteUi +import com.flxrs.dankchat.chat.compose.StackedEmote + +/** + * Renders a badge as inline content in a message. + */ +@Composable +fun BadgeInlineContent( + badge: BadgeUi, + size: Dp, + modifier: Modifier = Modifier +) { + AsyncImage( + model = badge.url, + contentDescription = badge.badge.type.name, + modifier = modifier.size(size) + ) +} + +/** + * Renders an emote (potentially stacked) as inline content in a message. + */ +@Composable +fun EmoteInlineContent( + emote: EmoteUi, + fontSize: Float, + coordinator: EmoteAnimationCoordinator, + animateGifs: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + StackedEmote( + emote = emote, + fontSize = fontSize, + emoteCoordinator = coordinator, + animateGifs = animateGifs, + modifier = modifier, + onClick = onClick + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt new file mode 100644 index 000000000..6bb7aa155 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt @@ -0,0 +1,160 @@ +package com.flxrs.dankchat.chat.compose.messages.common + +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +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.withStyle +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.EmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.EmoteUi +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName + +/** + * Appends a formatted timestamp to the AnnotatedString builder. + */ +fun AnnotatedString.Builder.appendTimestamp( + timestamp: String, + fontSize: TextUnit, + color: Color +) { + if (timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize.value * 0.95f).sp, + color = color, + letterSpacing = (-0.03).em + ) + ) { + append(timestamp) + append(" ") + } + } +} + +/** + * Appends badge inline content markers to the AnnotatedString builder. + */ +fun AnnotatedString.Builder.appendBadges(badges: List) { + badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") + } +} + +/** + * Appends message text with emotes, handling emote inline content and spacing. + */ +fun AnnotatedString.Builder.appendMessageWithEmotes( + message: String, + emotes: List, + textColor: Color +) { + withStyle(SpanStyle(color = textColor)) { + var currentPos = 0 + emotes.sortedBy { it.position.first }.forEach { emote -> + // Text before emote + if (currentPos < emote.position.first) { + append(message.substring(currentPos, emote.position.first)) + } + + // 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.length && !message[nextPos].isWhitespace()) { + append(" ") + } + + currentPos = emote.position.last + 1 + } + + // Remaining text + if (currentPos < message.length) { + append(message.substring(currentPos)) + } + } +} + +/** + * Appends a clickable username with annotation for click handling. + */ +fun AnnotatedString.Builder.appendClickableUsername( + displayText: String, + userId: UserId?, + userName: UserName, + displayName: DisplayName, + channel: String = "", + color: Color +) { + if (displayText.isNotEmpty()) { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = color + ) + ) { + val annotation = if (channel.isNotEmpty()) { + "${userId?.value ?: ""}|${userName.value}|${displayName.value}|$channel" + } else { + "${userId?.value ?: ""}|${userName.value}|${displayName.value}" + } + pushStringAnnotation( + tag = "USER", + annotation = annotation + ) + append(displayText) + pop() + } + } +} + +/** + * Builds inline content providers for badges and emotes. + */ +@Composable +fun buildInlineContentProviders( + badges: List, + emotes: List, + fontSize: Float, + coordinator: EmoteAnimationCoordinator, + animateGifs: Boolean, + onEmoteClick: (List) -> Unit +): Map Unit> { + val badgeSize = EmoteScaling.getBadgeSize(fontSize) + + return buildMap { + // Badge providers + badges.forEach { badge -> + put("BADGE_${badge.position}") { + BadgeInlineContent(badge = badge, size = badgeSize) + } + } + + // Emote providers + emotes.forEach { emote -> + put("EMOTE_${emote.code}") { + EmoteInlineContent( + emote = emote, + fontSize = fontSize, + coordinator = coordinator, + animateGifs = animateGifs, + onClick = { onEmoteClick(emotes) } + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt new file mode 100644 index 000000000..5baf1c15e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt @@ -0,0 +1,48 @@ +package com.flxrs.dankchat.chat.compose.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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.chat.compose.ChatMessageText +import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor +import com.flxrs.dankchat.chat.compose.rememberBackgroundColor + +/** + * A simple message container for system messages, notices, and other simple message types. + * Handles background color, padding, and text rendering consistently. + */ +@Composable +fun SimpleMessageContainer( + message: String, + timestamp: String, + fontSize: TextUnit, + lightBackgroundColor: Color, + darkBackgroundColor: Color, + textAlpha: Float, + modifier: Modifier = Modifier, +) { + val bgColor = rememberBackgroundColor(lightBackgroundColor, darkBackgroundColor) + val textColor = rememberAdaptiveTextColor(bgColor) + + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(bgColor) + .padding(vertical = 2.dp) + ) { + ChatMessageText( + text = message, + timestamp = timestamp, + fontSize = fontSize, + textColor = textColor.copy(alpha = textAlpha), + ) + } +} 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 index 3d86a9d42..b955c9745 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt @@ -4,40 +4,65 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.navArgs import com.flxrs.dankchat.chat.ChatFragment +import com.flxrs.dankchat.chat.compose.ChatScreen 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.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior -import com.flxrs.dankchat.utils.extensions.collectFlow +import com.flxrs.dankchat.theme.DankChatTheme +import org.koin.android.ext.android.inject 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() }) + private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() 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) + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value + val messages by when { + args.isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + } + DankChatTheme { + ChatScreen( + messages = messages, + fontSize = appearanceSettings.fontSize.toFloat(), + showChannelPrefix = !args.isWhisperTab, // Only show for mentions, not whispers + onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> + onUserClick( + targetUserId = userId?.let { UserId(it) }, + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.filterIsInstance(), + isLongPress = isLongPress + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) + }, + onEmoteClick = { + val chatEmotes = it.filterIsInstance() + (parentFragment?.parentFragment as? MainFragment)?.openEmoteSheet(chatEmotes) + } + ) + } } } - - when { - args.isWhisperTab -> collectFlow(mentionViewModel.whispers) { adapter.submitList(it) } - else -> collectFlow(mentionViewModel.mentions) { adapter.submitList(it) } - } - - return binding.root } override fun onUserClick( 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 index 1f0b84447..3cc3e1938 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt @@ -1,24 +1,66 @@ package com.flxrs.dankchat.chat.mention +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class MentionViewModel(chatRepository: ChatRepository) : ViewModel() { +class MentionViewModel( + chatRepository: ChatRepository, + private val context: Context, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, +) : 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 mentionsUiStates: StateFlow> = combine( + mentions, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings + ) { messages, appearanceSettings, chatSettings -> + messages.map { item -> + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + isAlternateBackground = false // No alternating in mentions + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) + + val whispersUiStates: StateFlow> = combine( + whispers, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings + ) { messages, appearanceSettings, chatSettings -> + messages.map { item -> + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + isAlternateBackground = false // No alternating in 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 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 index d874e23a2..1994b6589 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt @@ -4,44 +4,70 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.ChatFragment +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatScreen 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.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior -import com.flxrs.dankchat.utils.extensions.collectFlow +import com.flxrs.dankchat.theme.DankChatTheme import com.flxrs.dankchat.utils.extensions.showLongSnackbar +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class RepliesChatFragment : ChatFragment() { private val repliesViewModel: RepliesViewModel by viewModel(ownerProducer = { requireParentFragment() }) + private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() 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) - } - } + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value + + @Suppress("MoveVariableDeclarationIntoWhen") + val uiState = repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())).value - 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)) + when (uiState) { + is RepliesUiState.Found -> { + DankChatTheme { + ChatScreen( + messages = uiState.items, + fontSize = appearanceSettings.fontSize.toFloat(), + onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> + onUserClick( + targetUserId = userId?.let { UserId(it) }, + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map(BadgeUi::badge), + isLongPress = isLongPress + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) + }, + ) + } + } + + is RepliesUiState.NotFound -> { + // Show error - need to handle this in Compose or use side effect + androidx.compose.runtime.LaunchedEffect(Unit) { + view?.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) { 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 index 14bdf19ac..e86be8fcb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesViewModel.kt @@ -1,11 +1,18 @@ package com.flxrs.dankchat.chat.replies +import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.RepliesRepository +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel @@ -15,6 +22,9 @@ import kotlin.time.Duration.Companion.seconds class RepliesViewModel( repliesRepository: RepliesRepository, savedStateHandle: SavedStateHandle, + private val context: Context, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { private val args = RepliesFragmentArgs.fromSavedStateHandle(savedStateHandle) @@ -27,4 +37,30 @@ class RepliesViewModel( } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), RepliesState.Found(emptyList())) + + val uiState: StateFlow = combine( + state, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings + ) { repliesState, appearanceSettings, chatSettings -> + when (repliesState) { + is RepliesState.NotFound -> RepliesUiState.NotFound + is RepliesState.Found -> { + val uiMessages = repliesState.items.map { item -> + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + isAlternateBackground = false // No alternating in replies + ) + } + RepliesUiState.Found(uiMessages) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), RepliesUiState.Found(emptyList())) +} + +sealed interface RepliesUiState { + data object NotFound : RepliesUiState + data class Found(val items: List) : RepliesUiState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt b/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt index 4511b0261..41bf71657 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt @@ -8,8 +8,11 @@ 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.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode @@ -23,6 +26,22 @@ private val TrueDarkColorScheme = darkColorScheme( onBackground = Color.White, ) +/** + * Additional color values needed for dynamic text color selection + * based on background brightness. + */ +data class AdaptiveColors( + val onSurfaceLight: Color, + val onSurfaceDark: Color +) + +val LocalAdaptiveColors = staticCompositionLocalOf { + AdaptiveColors( + onSurfaceLight = lightColorScheme().onSurface, + onSurfaceDark = darkColorScheme().onSurface, + ) +} + @Composable fun DankChatTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -34,22 +53,36 @@ fun DankChatTheme( // 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( + + val lightColorScheme = when { + dynamicColor -> dynamicLightColorScheme(LocalContext.current) + else -> expressiveLightColorScheme() + } + val darkColorScheme = when { + dynamicColor && 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() + dynamicColor -> dynamicDarkColorScheme(LocalContext.current) + else -> darkColorScheme() + } + + val adaptiveColors = AdaptiveColors( + onSurfaceLight = lightColorScheme.onSurface, + onSurfaceDark = darkColorScheme.onSurface, + ) + val colors = when { + darkTheme -> darkColorScheme + else -> lightColorScheme } MaterialExpressiveTheme( motionScheme = MotionScheme.expressive(), colorScheme = colors, - content = content, - ) + ) { + CompositionLocalProvider(LocalAdaptiveColors provides adaptiveColors) { + content() + } + } } From 918cd662032bdc58162e56371eeb690b5bfcde77 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 003/349] feat(compose): Add ChatFragment Compose integration and feature toggle --- .../dankchat/chat/compose/ChatComposable.kt | 48 +++++++++++++++++ .../chat/mention/compose/MentionComposable.kt | 47 ++++++++++++++++ .../chat/replies/compose/RepliesComposable.kt | 53 +++++++++++++++++++ .../developer/DeveloperSettings.kt | 1 + .../developer/DeveloperSettingsFragment.kt | 6 +++ .../developer/DeveloperSettingsViewModel.kt | 2 + 6 files changed, 157 insertions(+) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt new file mode 100644 index 000000000..119fd1b33 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -0,0 +1,48 @@ +package com.flxrs.dankchat.chat.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.chat.ChatViewModel +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore + +/** + * Standalone composable for chat display. + * Extracted from ChatFragment to enable pure Compose integration. + * + * This composable: + * - Collects messages from ChatViewModel + * - Collects settings from data stores + * - Renders ChatScreen with all event handlers + */ +@Composable +fun ChatComposable( + chatViewModel: ChatViewModel, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, + 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) -> Unit, + modifier: Modifier = Modifier +) { + val messages by chatViewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) + val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) + + ChatScreen( + messages = messages, + fontSize = appearanceSettings.fontSize.toFloat(), + showLineSeparator = appearanceSettings.lineSeparator, + animateGifs = chatSettings.animateGifs, + modifier = modifier.fillMaxSize(), + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt new file mode 100644 index 000000000..47f7d99e2 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -0,0 +1,47 @@ +package com.flxrs.dankchat.chat.mention.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.mention.MentionViewModel +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore + +/** + * 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, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + 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, + modifier: Modifier = Modifier +) { + val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) + val messages by when { + isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + } + + ChatScreen( + messages = messages, + fontSize = appearanceSettings.fontSize.toFloat(), + showChannelPrefix = !isWhisperTab, + modifier = modifier, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt new file mode 100644 index 000000000..6abc0ee48 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -0,0 +1,53 @@ +package com.flxrs.dankchat.chat.replies.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.replies.RepliesUiState +import com.flxrs.dankchat.chat.replies.RepliesViewModel +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore + +/** + * 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 onNotFound callback + * - Renders ChatScreen for Found state + */ +@Composable +fun RepliesComposable( + repliesViewModel: RepliesViewModel, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onNotFound: () -> Unit, + modifier: Modifier = Modifier +) { + val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) + val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) + + when (uiState) { + is RepliesUiState.Found -> { + ChatScreen( + messages = (uiState as RepliesUiState.Found).items, + fontSize = appearanceSettings.fontSize.toFloat(), + modifier = modifier, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = { /* no-op for replies */ } + ) + } + is RepliesUiState.NotFound -> { + LaunchedEffect(Unit) { + onNotFound() + } + } + } +} 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..1e2cc65d4 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,6 +10,7 @@ data class DeveloperSettings( val customRecentMessagesHost: String = RM_HOST_DEFAULT, val eventSubEnabled: Boolean = true, val eventSubDebugOutput: Boolean = false, + val useComposeChatUi: Boolean = false, // Feature toggle for Compose migration ) { val isPubSubShutdown: Boolean get() = System.currentTimeMillis() > PUBSUB_SHUTDOWN_MILLIS 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 index 507459bea..195fe74b5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt @@ -196,6 +196,12 @@ private fun DeveloperSettings( isChecked = settings.bypassCommandHandling, onClick = { onInteraction(DeveloperSettingsInteraction.BypassCommandHandling(it)) }, ) + SwitchPreferenceItem( + title = "Use Compose Chat UI", + summary = "Enable new Compose-based chat interface (experimental)", + isChecked = settings.useComposeChatUi, + onClick = { onInteraction(DeveloperSettingsInteraction.UseComposeChatUi(it)) }, + ) ExpandablePreferenceItem(title = stringResource(R.string.preference_custom_login_title)) { CustomLoginBottomSheet( onDismissRequested = ::dismiss, 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..8bed617fd 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 @@ -36,6 +36,7 @@ class DeveloperSettingsViewModel( 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.UseComposeChatUi -> developerSettingsDataStore.update { it.copy(useComposeChatUi = interaction.value) } is DeveloperSettingsInteraction.CustomRecentMessagesHost -> { val withSlash = interaction.host .ifBlank { DeveloperSettings.RM_HOST_DEFAULT } @@ -67,6 +68,7 @@ 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 UseComposeChatUi(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 From 4c8a8a5a0bf8f465bce4d7a9ff65246b4a7d232c Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 004/349] feat(compose): Implement MainScreen with scoped ViewModels --- .../dankchat/chat/compose/ChatComposable.kt | 25 +- .../chat/compose/ChatComposeViewModel.kt | 62 ++++ .../chat/compose/ChatMessageMapper.kt | 23 ++ .../chat/compose/ChatMessageUiState.kt | 8 + .../flxrs/dankchat/chat/compose/ChatScreen.kt | 2 +- .../data/state/ChannelLoadingState.kt | 52 ++++ .../dankchat/domain/ChannelDataCoordinator.kt | 119 ++++++++ .../dankchat/domain/ChannelDataLoader.kt | 131 +++++++++ .../flxrs/dankchat/domain/GlobalDataLoader.kt | 49 ++++ .../com/flxrs/dankchat/main/MainActivity.kt | 145 +++++++-- .../dankchat/main/compose/ChatInputLayout.kt | 60 ++++ .../main/compose/EmptyStateContent.kt | 91 ++++++ .../flxrs/dankchat/main/compose/MainAppBar.kt | 275 ++++++++++++++++++ .../flxrs/dankchat/main/compose/MainScreen.kt | 211 ++++++++++++++ .../main/compose/MainScreenViewModel.kt | 116 ++++++++ .../main/compose/SheetNavigationViewModel.kt | 67 +++++ .../utils/compose/RoundedCornerPadding.kt | 166 +++++++++++ 17 files changed, 1571 insertions(+), 31 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 119fd1b33..74b7b88a3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -5,32 +5,43 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flxrs.dankchat.chat.ChatViewModel +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf /** * Standalone composable for chat display. * Extracted from ChatFragment to enable pure Compose integration. * * This composable: - * - Collects messages from ChatViewModel + * - Creates its own ChatComposeViewModel scoped to the channel + * - Collects messages from ViewModel * - Collects settings from data stores * - Renders ChatScreen with all event handlers */ @Composable fun ChatComposable( - chatViewModel: ChatViewModel, - appearanceSettingsDataStore: AppearanceSettingsDataStore, - chatSettingsDataStore: ChatSettingsDataStore, + 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) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val messages by chatViewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + // Create ChatComposeViewModel with channel-specific key for proper scoping + val viewModel: ChatComposeViewModel = koinViewModel( + key = channel.value, + parameters = { parametersOf(channel) } + ) + + val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() + val chatSettingsDataStore: ChatSettingsDataStore = koinInject() + + val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt new file mode 100644 index 000000000..8aaadf7e1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -0,0 +1,62 @@ +package com.flxrs.dankchat.chat.compose + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.extensions.isEven +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.InjectedParam +import kotlin.time.Duration.Companion.seconds + +/** + * ViewModel for Compose-based chat display. + * + * Unlike ChatViewModel (which uses SavedStateHandle with Fragment navigation args), + * this ViewModel takes the channel directly as a constructor parameter, making it + * suitable for Compose usage where we can pass parameters via koinViewModel(). + */ +@KoinViewModel +class ChatComposeViewModel( + @InjectedParam private val channel: UserName, + private val repository: ChatRepository, + private val context: Context, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, +) : ViewModel() { + + private val chat: StateFlow> = repository + .getChat(channel) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) + + val chatUiStates: StateFlow> = combine( + chat, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings + ) { messages, appearanceSettings, chatSettings -> + var messageCount = 0 + messages.mapIndexed { index, item -> + val isAlternateBackground = when (index) { + messages.lastIndex -> messageCount++.isEven + else -> (index - messages.size - 1).isEven + } + + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground && appearanceSettings.checkeredMessages + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index c804abeb3..c897fe984 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.chat.compose import android.content.Context +import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import com.flxrs.dankchat.R @@ -76,6 +77,7 @@ object ChatMessageMapper { return when (val msg = message) { is SystemMessage -> msg.toSystemMessageUi( + tag = this.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -83,6 +85,7 @@ object ChatMessageMapper { ) is NoticeMessage -> msg.toNoticeMessageUi( + tag = this.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -90,6 +93,7 @@ object ChatMessageMapper { ) is UserNoticeMessage -> msg.toUserNoticeMessageUi( + tag = this.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -97,6 +101,7 @@ object ChatMessageMapper { ) is PrivMessage -> msg.toPrivMessageUi( + tag = this.tag, context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, @@ -107,6 +112,7 @@ object ChatMessageMapper { ) is ModerationMessage -> msg.toModerationMessageUi( + tag = this.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -114,12 +120,14 @@ object ChatMessageMapper { ) is PointRedemptionMessage -> msg.toPointRedemptionMessageUi( + tag = this.tag, context = context, chatSettings = chatSettings, textAlpha = textAlpha ) is WhisperMessage -> msg.toWhisperMessageUi( + tag = this.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -129,6 +137,7 @@ object ChatMessageMapper { } private fun SystemMessage.toSystemMessageUi( + tag: Int, context: Context, chatSettings: ChatSettings, isAlternateBackground: Boolean, @@ -171,6 +180,7 @@ object ChatMessageMapper { return ChatMessageUiState.SystemMessageUi( id = id, + tag = tag, timestamp = timestamp, lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, @@ -180,6 +190,7 @@ object ChatMessageMapper { } private fun NoticeMessage.toNoticeMessageUi( + tag: Int, context: Context, chatSettings: ChatSettings, isAlternateBackground: Boolean, @@ -192,6 +203,7 @@ object ChatMessageMapper { return ChatMessageUiState.NoticeMessageUi( id = id, + tag = tag, timestamp = timestamp, lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, @@ -201,6 +213,7 @@ object ChatMessageMapper { } private fun UserNoticeMessage.toUserNoticeMessageUi( + tag: Int, context: Context, chatSettings: ChatSettings, isAlternateBackground: Boolean, @@ -220,6 +233,7 @@ object ChatMessageMapper { return ChatMessageUiState.UserNoticeMessageUi( id = id, + tag = tag, timestamp = timestamp, lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, @@ -230,6 +244,7 @@ object ChatMessageMapper { } private fun ModerationMessage.toModerationMessageUi( + tag: Int, context: Context, chatSettings: ChatSettings, isAlternateBackground: Boolean, @@ -242,6 +257,7 @@ object ChatMessageMapper { return ChatMessageUiState.ModerationMessageUi( id = id, + tag = tag, timestamp = timestamp, lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, @@ -251,6 +267,7 @@ object ChatMessageMapper { } private fun PrivMessage.toPrivMessageUi( + tag: Int, context: Context, appearanceSettings: AppearanceSettings, chatSettings: ChatSettings, @@ -309,6 +326,7 @@ object ChatMessageMapper { emotes = emoteGroup ) } + Log.d("XXX", "emotes: $emoteUis") val threadUi = if (thread != null && !isInReplies) { thread.toThreadUi() @@ -331,6 +349,7 @@ object ChatMessageMapper { return ChatMessageUiState.PrivMessageUi( id = id, + tag = tag, timestamp = timestamp, lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, @@ -353,6 +372,7 @@ object ChatMessageMapper { } private fun PointRedemptionMessage.toPointRedemptionMessageUi( + tag: Int, context: Context, chatSettings: ChatSettings, textAlpha: Float, @@ -366,6 +386,7 @@ object ChatMessageMapper { return ChatMessageUiState.PointRedemptionMessageUi( id = id, + tag = tag, timestamp = timestamp, lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, @@ -379,6 +400,7 @@ object ChatMessageMapper { } private fun WhisperMessage.toWhisperMessageUi( + tag: Int, context: Context, chatSettings: ChatSettings, isAlternateBackground: Boolean, @@ -440,6 +462,7 @@ object ChatMessageMapper { return ChatMessageUiState.WhisperMessageUi( id = id, + tag = tag, timestamp = timestamp, lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 3c99d5d14..d5ec053f7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -16,6 +16,7 @@ import com.flxrs.dankchat.data.twitch.message.MessageThreadHeader @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 @@ -28,6 +29,7 @@ sealed interface ChatMessageUiState { @Immutable data class PrivMessageUi( override val id: String, + override val tag: Int, override val timestamp: String, override val lightBackgroundColor: Color, override val darkBackgroundColor: Color, @@ -54,6 +56,7 @@ sealed interface 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, @@ -68,6 +71,7 @@ sealed interface 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, @@ -82,6 +86,7 @@ sealed interface 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, @@ -97,6 +102,7 @@ sealed interface 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, @@ -111,6 +117,7 @@ sealed interface 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, @@ -129,6 +136,7 @@ sealed interface ChatMessageUiState { @Immutable data class WhisperMessageUi( override val id: String, + override val tag: Int, override val timestamp: String, override val lightBackgroundColor: Color, override val darkBackgroundColor: Color, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index a334d4d6d..33e6c480b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -97,7 +97,7 @@ fun ChatScreen( ) { items( items = messages.asReversed(), - key = { message -> message.id }, + key = { message -> "${message.id}-${message.tag}" }, contentType = { message -> when (message) { is ChatMessageUiState.SystemMessageUi -> "system" 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..3646f9c9c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -0,0 +1,52 @@ +package com.flxrs.dankchat.data.state + +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName + +sealed interface ChannelLoadingState { + data object Idle : ChannelLoadingState + data object Loading : ChannelLoadingState + data object Loaded : ChannelLoadingState + data class Failed( + val message: String, + 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 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 message: String) : GlobalLoadingState +} 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..0656f7adc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -0,0 +1,119 @@ +package com.flxrs.dankchat.domain + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +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 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 +import java.util.concurrent.ConcurrentHashMap + +@Single +class ChannelDataCoordinator( + private val channelDataLoader: ChannelDataLoader, + private val globalDataLoader: GlobalDataLoader, + private val userStateRepository: UserStateRepository, + private val preferenceStore: DankChatPreferenceStore, + dispatchersProvider: DispatchersProvider +) { + + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + + // Track loading state per channel + private val channelStates = ConcurrentHashMap>() + + // Global loading state + private val _globalLoadingState = MutableStateFlow(GlobalLoadingState.Idle) + val globalLoadingState: StateFlow = _globalLoadingState.asStateFlow() + + /** + * Get loading state for a specific channel + */ + fun getChannelLoadingState(channel: UserName): StateFlow { + return channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) + } + } + + /** + * Load data when a channel is added + */ + fun loadChannelData(channel: UserName) { + scope.launch { + val stateFlow = channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) + } + + channelDataLoader.loadChannelData(channel) + .collect { state -> + stateFlow.value = state + } + } + } + + /** + * Load global data (once at startup) + */ + fun loadGlobalData() { + scope.launch { + _globalLoadingState.value = GlobalLoadingState.Loading + + globalDataLoader.loadGlobalData() + .onSuccess { + // Load user state emotes if logged in + if (preferenceStore.isLoggedIn) { + loadUserStateEmotesIfAvailable() + } + _globalLoadingState.value = GlobalLoadingState.Loaded + } + .onFailure { error -> + _globalLoadingState.value = GlobalLoadingState.Failed(error.message ?: "Unknown error") + } + } + } + + /** + * Load user-specific emotes after global data is loaded + */ + private suspend fun loadUserStateEmotesIfAvailable() { + val channels = preferenceStore.channels + if (channels.isEmpty()) return + + val userState = userStateRepository.tryGetUserStateOrFallback(minChannelsSize = channels.size) + userState?.let { + globalDataLoader.loadUserStateEmotes(it.globalEmoteSets, it.followerEmoteSets) + } + } + + /** + * Cleanup when a channel is removed + */ + fun cleanupChannel(channel: UserName) { + channelStates.remove(channel) + } + + /** + * Reload all channels (e.g., on reconnect) + */ + fun reloadAllChannels() { + scope.launch { + preferenceStore.channels.forEach { channel -> + loadChannelData(channel) + } + } + } + + /** + * Reload global data + */ + fun reloadGlobalData() { + loadGlobalData() + } +} 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..b327a52d4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -0,0 +1,131 @@ +package com.flxrs.dankchat.domain + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.repo.channel.Channel +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +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.di.DispatchersProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single + +@Single +class ChannelDataLoader( + private val dataRepository: DataRepository, + private val chatRepository: ChatRepository, + private val channelRepository: ChannelRepository, + private val dispatchersProvider: DispatchersProvider +) { + + /** + * Load all data for a single channel. + * Returns a Flow of loading state for this channel. + */ + fun loadChannelData(channel: UserName): Flow = flow { + emit(ChannelLoadingState.Loading) + + try { + // Get channel info + val channelInfo = channelRepository.getChannel(channel) + if (channelInfo == null) { + emit(ChannelLoadingState.Failed("Channel not found", emptyList())) + return@flow + } + + // Create flows if necessary + dataRepository.createFlowsIfNecessary(listOf(channel)) + chatRepository.createFlowsIfNecessary(channel) + + // Load in parallel and collect all failures + val failures = withContext(dispatchersProvider.io) { + val badgesResult = async { loadChannelBadges(channel, channelInfo.id) } + val emotesResults = async { loadChannelEmotes(channel, channelInfo) } + val messagesResult = async { loadRecentMessages(channel) } + + listOfNotNull( + badgesResult.await(), + *emotesResults.await().toTypedArray(), + messagesResult.await() + ) + } + + // Reparse emotes/badges - this updates the tag which triggers LazyColumn recomposition + chatRepository.reparseAllEmotesAndBadges() + + when { + failures.isEmpty() -> emit(ChannelLoadingState.Loaded) + else -> emit(ChannelLoadingState.Failed("Some data failed to load", failures)) + } + } catch (e: Exception) { + emit(ChannelLoadingState.Failed(e.message ?: "Unknown error", emptyList())) + } + } + + private suspend fun loadChannelBadges( + channel: UserName, + channelId: UserId + ): ChannelLoadingFailure.Badges? { + return runCatching { + dataRepository.loadChannelBadges(channel, channelId) + }.fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.Badges(channel, channelId, it) } + ) + } + + private suspend fun loadChannelEmotes( + channel: UserName, + channelInfo: Channel + ): List { + return withContext(dispatchersProvider.io) { + val bttvResult = async { + runCatching { + dataRepository.loadChannelBTTVEmotes(channel, channelInfo.displayName, channelInfo.id) + }.fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.BTTVEmotes(channel, it) } + ) + } + val ffzResult = async { + runCatching { + dataRepository.loadChannelFFZEmotes(channel, channelInfo.id) + }.fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.FFZEmotes(channel, it) } + ) + } + val sevenTvResult = async { + runCatching { + dataRepository.loadChannelSevenTVEmotes(channel, channelInfo.id) + }.fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.SevenTVEmotes(channel, it) } + ) + } + + listOfNotNull( + bttvResult.await(), + ffzResult.await(), + sevenTvResult.await() + ) + } + } + + private suspend fun loadRecentMessages( + channel: UserName + ): ChannelLoadingFailure.RecentMessages? { + return runCatching { + chatRepository.loadRecentMessagesIfEnabled(channel) + }.fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.RecentMessages(channel, it) } + ) + } +} 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..327400f9d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -0,0 +1,49 @@ +package com.flxrs.dankchat.domain + +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.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +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 +) { + + /** + * Load all global data (badges, emotes, commands, blocks) + */ + suspend fun loadGlobalData(): Result = withContext(dispatchersProvider.io) { + runCatching { + awaitAll( + async { dataRepository.loadDankChatBadges() }, + async { dataRepository.loadGlobalBadges() }, + async { dataRepository.loadGlobalBTTVEmotes() }, + async { dataRepository.loadGlobalFFZEmotes() }, + async { dataRepository.loadGlobalSevenTVEmotes() }, + async { commandRepository.loadSupibotCommands() }, + async { ignoresRepository.loadUserBlocks() } + ) + Unit + } + } + + /** + * Load user-specific global emotes (requires login) + */ + suspend fun loadUserStateEmotes( + globalEmoteSets: List, + followerEmoteSets: Map> + ) { + dataRepository.loadUserStateEmotes(globalEmoteSets, followerEmoteSets) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 4d8d18d73..9e8efb0f0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -10,15 +10,18 @@ import android.os.Build import android.os.Bundle import android.os.IBinder import android.util.Log +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue 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.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController @@ -29,6 +32,9 @@ 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.main.compose.MainScreen +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import com.flxrs.dankchat.theme.DankChatTheme import com.flxrs.dankchat.utils.extensions.hasPermission import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu import com.flxrs.dankchat.utils.extensions.isInSupportedPictureInPictureMode @@ -38,15 +44,21 @@ 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.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : AppCompatActivity() { private val viewModel: DankChatViewModel by viewModel() + private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() + private val dankChatPreferenceStore: com.flxrs.dankchat.preferences.DankChatPreferenceStore by inject() private val pendingChannelsToClear = mutableListOf() - private val navController: NavController by lazy { findNavController(R.id.main_content) } + private var navController: NavController? = null private var bindingRef: MainActivityBinding? = null - private val binding get() = bindingRef!! + private val binding get() = bindingRef + + private val isLoggedIn: Boolean + get() = dankChatPreferenceStore.isLoggedIn private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { // just start the service, we don't care if the permission has been granted or not xd @@ -82,8 +94,15 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - bindingRef = MainActivityBinding.inflate(layoutInflater) - setContentView(binding.root) + + // Check if we should use Compose UI + val useComposeUi = developerSettingsDataStore.current().useComposeChatUi + + if (useComposeUi) { + setupComposeUi() + } else { + setupFragmentUi() + } viewModel.checkLogin() viewModel.serviceEvents @@ -105,6 +124,83 @@ class MainActivity : AppCompatActivity() { .launchIn(lifecycleScope) } + private fun setupFragmentUi() { + bindingRef = MainActivityBinding.inflate(layoutInflater) + setContentView(binding!!.root) + navController = findNavController(R.id.main_content) + } + + private fun setupComposeUi() { + setContent { + DankChatTheme { + val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle( + initialValue = developerSettingsDataStore.current() + ) + + MainScreen( + isLoggedIn = isLoggedIn, + onNavigateToSettings = { + // TODO: Navigate to settings (need to implement Compose settings or use dialog) + }, + onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> + // TODO: Show user popup dialog + }, + onMessageLongClick = { messageId, channel, fullMessage -> + // TODO: Show message options dialog + }, + onEmoteClick = { emotes -> + // TODO: Show emote overlay/fullscreen + }, + onLogin = { + // TODO: Navigate to login + }, + onRelogin = { + // TODO: Navigate to login + }, + onLogout = { + // TODO: Show logout confirmation + }, + onManageChannels = { + // TODO: Show manage channels dialog + }, + onOpenChannel = { + // TODO: Open channel in browser + }, + onRemoveChannel = { + // TODO: Remove active channel + }, + onReportChannel = { + // TODO: Report channel + }, + onBlockChannel = { + // TODO: Block channel + }, + onReloadEmotes = { + // TODO: Reload emotes + }, + onReconnect = { + // TODO: Reconnect to chat + }, + onClearChat = { + // TODO: Clear chat messages + }, + onCaptureImage = { + // TODO: Capture image + }, + onCaptureVideo = { + // TODO: Capture video + }, + onChooseMedia = { + // TODO: Choose media + }, + onAddChannel = { + // TODO: Show add channel dialog + } + ) + } + } + } + override fun onDestroy() { super.onDestroy() bindingRef = null @@ -154,7 +250,7 @@ class MainActivity : AppCompatActivity() { } override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp() || super.onSupportNavigateUp() + return navController?.navigateUp() ?: false || super.onSupportNavigateUp() } override fun onNewIntent(intent: Intent) { @@ -168,30 +264,33 @@ class MainActivity : AppCompatActivity() { else -> pendingChannelsToClear += channel } - fun setFullScreen(enabled: Boolean, changeActionBarVisibility: Boolean = true) = binding.root.doOnAttach { - val windowInsetsController = WindowCompat.getInsetsController(window, it) - when { - enabled -> { - // minSdk 30 guarantees multi-window support (API 24+) - if (!isInMultiWindowMode) { - with(windowInsetsController) { - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - hide(Type.systemBars()) + fun setFullScreen(enabled: Boolean, changeActionBarVisibility: Boolean = true) { + val rootView = binding?.root ?: return + rootView.doOnAttach { + val windowInsetsController = WindowCompat.getInsetsController(window, it) + when { + enabled -> { + // minSdk 30 guarantees multi-window support (API 24+) + if (!isInMultiWindowMode) { + with(windowInsetsController) { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(Type.systemBars()) + } + } + if (changeActionBarVisibility) { + supportActionBar?.hide() } } - if (changeActionBarVisibility) { - supportActionBar?.hide() - } - } - else -> { - windowInsetsController.show(Type.systemBars()) - if (changeActionBarVisibility) { - supportActionBar?.show() + else -> { + windowInsetsController.show(Type.systemBars()) + if (changeActionBarVisibility) { + supportActionBar?.show() + } } } + it.requestApplyInsets() } - it.requestApplyInsets() } private fun handleShutDown() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt new file mode 100644 index 000000000..4bf00c066 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -0,0 +1,60 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.utils.compose.avoidRoundedCorners + +@Composable +fun ChatInputLayout( + inputText: String, + onInputChange: (String) -> Unit, + onSend: () -> Unit, + onEmoteClick: () -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + leadingIcon = { + IconButton(onClick = onEmoteClick) { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = stringResource(R.string.emote_menu_hint) + ) + } + }, + trailingIcon = { + IconButton( + onClick = onSend, + enabled = inputText.isNotBlank() + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.send_hint) + ) + } + }, + value = inputText, + onValueChange = onInputChange, + modifier = modifier + .fillMaxWidth() + .avoidRoundedCorners(fallback = PaddingValues()), + label = { + // TODO + Text(stringResource(R.string.hint_connected)) + }, + maxLines = 5, + singleLine = false + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt new file mode 100644 index 000000000..3cf76c661 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt @@ -0,0 +1,91 @@ +package com.flxrs.dankchat.main.compose + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Login +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.components.DankBackground + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun EmptyStateContent( + isLoggedIn: Boolean, + onAddChannel: () -> Unit, + onLogin: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + DankBackground(visible = true) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(32.dp) + ) { + Text( + text = stringResource(R.string.no_channels_added), + style = MaterialTheme.typography.headlineMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.add_channel_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Quick action chips + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AssistChip( + onClick = onAddChannel, + label = { Text(stringResource(R.string.add_channel)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + } + ) + + if (!isLoggedIn) { + AssistChip( + onClick = onLogin, + label = { Text(stringResource(R.string.login)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Login, + contentDescription = null + ) + } + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt new file mode 100644 index 000000000..adf0a28fb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -0,0 +1,275 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.res.stringResource +import com.flxrs.dankchat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainAppBar( + isLoggedIn: Boolean, + hasChannels: Boolean, + totalMentionCount: Int, + onAddChannel: () -> Unit, + onOpenMentions: () -> Unit, + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier +) { + var showMenu by remember { mutableStateOf(false) } + var showChannelMenu by remember { mutableStateOf(false) } + var showAccountMenu by remember { mutableStateOf(false) } + var showUploadMenu by remember { mutableStateOf(false) } + var showMoreMenu by remember { mutableStateOf(false) } + + TopAppBar( + title = { Text("DankChat") }, + actions = { + // Add channel button (always visible) + IconButton(onClick = onAddChannel) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_channel) + ) + } + + // Mentions button (visible if channels exist) + if (hasChannels) { + IconButton(onClick = onOpenMentions) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.mentions_title) + ) + // TODO: Apply color tint for mentions indicator + } + } + + // Overflow menu + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more) + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + // Login/Account section + if (!isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.login)) }, + onClick = { + onLogin() + showMenu = false + } + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.account)) }, + onClick = { + showAccountMenu = true + } + ) + + DropdownMenu( + expanded = showAccountMenu, + onDismissRequest = { showAccountMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.relogin)) }, + onClick = { + onRelogin() + showAccountMenu = false + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.logout)) }, + onClick = { + onLogout() + showAccountMenu = false + showMenu = false + } + ) + } + } + + // Manage channels + if (hasChannels) { + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_channels)) }, + onClick = { + onManageChannels() + showMenu = false + } + ) + + // Channel submenu + DropdownMenuItem( + text = { Text(stringResource(R.string.channel)) }, + onClick = { + showChannelMenu = true + } + ) + + DropdownMenu( + expanded = showChannelMenu, + onDismissRequest = { showChannelMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.open_channel)) }, + onClick = { + onOpenChannel() + showChannelMenu = false + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_channel)) }, + onClick = { + onRemoveChannel() + showChannelMenu = false + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_channel)) }, + onClick = { + onReportChannel() + showChannelMenu = false + showMenu = false + } + ) + if (isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_channel)) }, + onClick = { + onBlockChannel() + showChannelMenu = false + showMenu = false + } + ) + } + } + } + + // Upload media submenu + DropdownMenuItem( + text = { Text(stringResource(R.string.upload_media)) }, + onClick = { + showUploadMenu = true + } + ) + + DropdownMenu( + expanded = showUploadMenu, + onDismissRequest = { showUploadMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.take_picture)) }, + onClick = { + onCaptureImage() + showUploadMenu = false + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.record_video)) }, + onClick = { + onCaptureVideo() + showUploadMenu = false + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.choose_media)) }, + onClick = { + onChooseMedia() + showUploadMenu = false + showMenu = false + } + ) + } + + // More submenu + DropdownMenuItem( + text = { Text(stringResource(R.string.more)) }, + onClick = { + showMoreMenu = true + } + ) + + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.reload_emotes)) }, + onClick = { + onReloadEmotes() + showMoreMenu = false + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.reconnect)) }, + onClick = { + onReconnect() + showMoreMenu = false + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.clear_chat)) }, + onClick = { + onClearChat() + showMoreMenu = false + showMenu = false + } + ) + } + + // Settings + DropdownMenuItem( + text = { Text(stringResource(R.string.settings)) }, + onClick = { + onOpenSettings() + showMenu = false + } + ) + } + }, + modifier = modifier + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt new file mode 100644 index 000000000..4339fcd30 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -0,0 +1,211 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatComposable +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + isLoggedIn: Boolean, + onNavigateToSettings: () -> 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, + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, + onAddChannel: () -> Unit, + modifier: Modifier = Modifier +) { + val mainScreenViewModel: MainScreenViewModel = koinViewModel() + val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + + val channels by mainScreenViewModel.channels.collectAsStateWithLifecycle() + val activeChannel by mainScreenViewModel.activeChannel.collectAsStateWithLifecycle() + val activeChannelIndex by mainScreenViewModel.activeChannelIndex.collectAsStateWithLifecycle() + val unreadMessagesMap by mainScreenViewModel.unreadMessagesMap.collectAsStateWithLifecycle() + val channelMentionCount by mainScreenViewModel.channelMentionCount.collectAsStateWithLifecycle() + val totalMentionCount by mainScreenViewModel.totalMentionCount.collectAsStateWithLifecycle() + + // Simple local input state (will be replaced with ChatInputViewModel later) + var inputText by remember { mutableStateOf("") } + + val pagerState = rememberPagerState( + initialPage = activeChannelIndex, + pageCount = { channels.size } + ) + val coroutineScope = rememberCoroutineScope() + + // Sync pager with active channel + LaunchedEffect(activeChannelIndex) { + if (pagerState.currentPage != activeChannelIndex && activeChannelIndex < channels.size) { + pagerState.animateScrollToPage(activeChannelIndex) + } + } + + // Update active channel when user swipes + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage < channels.size) { + val channel = channels[pagerState.currentPage].channel + mainScreenViewModel.setActiveChannel(channel) + } + } + + Scaffold( + topBar = { + MainAppBar( + isLoggedIn = isLoggedIn, + hasChannels = channels.isNotEmpty(), + totalMentionCount = totalMentionCount, + onAddChannel = onAddChannel, + onOpenMentions = { sheetNavigationViewModel.openMentions() }, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = onLogout, + onManageChannels = onManageChannels, + onOpenChannel = onOpenChannel, + onRemoveChannel = onRemoveChannel, + onReportChannel = onReportChannel, + onBlockChannel = onBlockChannel, + onReloadEmotes = onReloadEmotes, + onReconnect = onReconnect, + onClearChat = onClearChat, + onCaptureImage = onCaptureImage, + onCaptureVideo = onCaptureVideo, + onChooseMedia = onChooseMedia, + onOpenSettings = onNavigateToSettings + ) + }, + modifier = modifier + .fillMaxSize() + .imePadding(), + ) { paddingValues -> + if (channels.isEmpty()) { + EmptyStateContent( + isLoggedIn = isLoggedIn, + onAddChannel = onAddChannel, + onLogin = onLogin, + modifier = Modifier.padding(paddingValues) + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Tabs + PrimaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + ) { + channels.forEachIndexed { index, channelWithRename -> + val channel = channelWithRename.channel + val displayName = channelWithRename.rename?.value ?: channel.value + val hasUnread = unreadMessagesMap[channel] == true + val mentionCount = channelMentionCount[channel] ?: 0 + + Tab( + selected = index == pagerState.currentPage, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + BadgedBox( + badge = { + // TODO proper viewmodel state for this + if (mentionCount > 0) { + Badge() + } + }, + content = { + Text(text = channel.value) + } + ) + }, + ) + } + } + + // Chat pager + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { page -> + if (page in channels.indices) { + val channel = channels[page].channel + ChatComposable( + channel = channel, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = { replyMessageId -> + sheetNavigationViewModel.openReplies(replyMessageId) + } + ) + } + } + + // Input at bottom + ChatInputLayout( + inputText = inputText, + onInputChange = { inputText = it }, + onSend = { + // TODO: Implement send via ChatRepository + inputText = "" + }, + onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, + modifier = Modifier + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt new file mode 100644 index 000000000..e5b3157ba --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -0,0 +1,116 @@ +package com.flxrs.dankchat.main.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.model.ChannelWithRename +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class MainScreenViewModel( + private val chatRepository: ChatRepository, + private val channelRepository: ChannelRepository, + private val preferenceStore: DankChatPreferenceStore, + private val channelDataCoordinator: ChannelDataCoordinator, +) : ViewModel() { + + val channels: StateFlow> = preferenceStore.getChannelsWithRenamesFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val activeChannel: StateFlow = chatRepository.activeChannel + + val activeChannelIndex: StateFlow = combine(channels, activeChannel) { channels, active -> + channels.indexOfFirst { it.channel == active }.coerceAtLeast(0) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + val unreadMessagesMap: StateFlow> = + chatRepository.unreadMessagesMap + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + val channelMentionCount: StateFlow> = + chatRepository.channelMentionCount + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + val totalMentionCount: StateFlow = channelMentionCount.map { it.values.sum() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + // Global loading state + val globalLoadingState: StateFlow = channelDataCoordinator.globalLoadingState + + init { + // Load global data once at startup + channelDataCoordinator.loadGlobalData() + + // Observe channels and load data when added + viewModelScope.launch { + channels.collect { channelList -> + channelList.forEach { channelWithRename -> + channelDataCoordinator.loadChannelData(channelWithRename.channel) + } + } + } + } + + /** + * Get loading state for a specific channel + */ + fun getChannelLoadingState(channel: UserName): StateFlow { + return channelDataCoordinator.getChannelLoadingState(channel) + } + + fun setActiveChannel(channel: UserName) { + chatRepository.setActiveChannel(channel) + clearUnreadMessage(channel) + clearMentionCount(channel) + } + + fun addChannel(channel: UserName) { + val currentChannels = preferenceStore.channels + if (channel !in currentChannels) { + preferenceStore.channels = currentChannels + channel + // Data loading triggered automatically by channel observer + } + } + + fun removeChannel(channel: UserName) { + preferenceStore.removeChannel(channel) + channelDataCoordinator.cleanupChannel(channel) + } + + fun renameChannel(channel: UserName, displayName: String?) { + val rename = displayName?.ifBlank { null }?.let { UserName(it) } + preferenceStore.setRenamedChannel(ChannelWithRename(channel, rename)) + } + + fun clearUnreadMessage(channel: UserName) { + chatRepository.clearUnreadMessage(channel) + } + + fun clearMentionCount(channel: UserName) { + chatRepository.clearMentionCount(channel) + } + + fun retryChannelLoading(channel: UserName) { + channelDataCoordinator.loadChannelData(channel) + } + + fun reloadAllChannels() { + channelDataCoordinator.reloadAllChannels() + } + + fun reloadGlobalData() { + channelDataCoordinator.reloadGlobalData() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt new file mode 100644 index 000000000..952e6af94 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt @@ -0,0 +1,67 @@ +package com.flxrs.dankchat.main.compose + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class SheetNavigationViewModel : ViewModel() { + + private val _fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) + val fullScreenSheetState: StateFlow = _fullScreenSheetState.asStateFlow() + + private val _inputSheetState = MutableStateFlow(InputSheetState.Closed) + val inputSheetState: StateFlow = _inputSheetState.asStateFlow() + + fun openReplies(rootMessageId: String) { + _fullScreenSheetState.value = FullScreenSheetState.Replies(rootMessageId) + } + + fun openMentions() { + _fullScreenSheetState.value = FullScreenSheetState.Mention + } + + fun openWhispers() { + _fullScreenSheetState.value = FullScreenSheetState.Whisper + } + + fun closeFullScreenSheet() { + _fullScreenSheetState.value = FullScreenSheetState.Closed + } + + fun openEmoteSheet() { + _inputSheetState.value = InputSheetState.EmoteMenu + } + + fun closeInputSheet() { + _inputSheetState.value = InputSheetState.Closed + } + + fun handleBackPress(): Boolean { + return when { + _inputSheetState.value != InputSheetState.Closed -> { + closeInputSheet() + true + } + _fullScreenSheetState.value != FullScreenSheetState.Closed -> { + closeFullScreenSheet() + true + } + else -> false + } + } +} + +sealed interface FullScreenSheetState { + data object Closed : FullScreenSheetState + data class Replies(val replyMessageId: String) : FullScreenSheetState + data object Mention : FullScreenSheetState + data object Whisper : FullScreenSheetState +} + +sealed interface InputSheetState { + data object Closed : InputSheetState + data object EmoteMenu : InputSheetState +} 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..6cf52e71d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -0,0 +1,166 @@ +package com.flxrs.dankchat.utils.compose + +import android.os.Build +import android.view.RoundedCorner +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.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.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. + */ +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 + ) +} + +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) +} + +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) +} + +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) +} + +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) +} From 6807abfef33dd4271932e81fac1f9cf50f4f3824 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 005/349] feat(compose): Add autocomplete suggestions for chat input --- .../chat/suggestion/SuggestionProvider.kt | 146 +++++++++++++ .../compose/ChannelManagementViewModel.kt | 73 +++++++ .../main/compose/ChannelPagerViewModel.kt | 42 ++++ .../main/compose/ChannelTabViewModel.kt | 71 +++++++ .../dankchat/main/compose/ChatInputLayout.kt | 23 +- .../main/compose/ChatInputViewModel.kt | 110 ++++++++++ .../flxrs/dankchat/main/compose/MainScreen.kt | 199 ++++++++++-------- .../main/compose/MainScreenViewModel.kt | 107 ++-------- .../main/compose/SuggestionDropdown.kt | 117 ++++++++++ 9 files changed, 694 insertions(+), 194 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt new file mode 100644 index 000000000..8e79ff830 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt @@ -0,0 +1,146 @@ +package com.flxrs.dankchat.chat.suggestion + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.UsersRepository +import com.flxrs.dankchat.data.repo.emote.EmoteRepository +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +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 + +/** + * Provides suggestion filtering logic for chat input. + * Filters emotes, users, and commands based on input text. + */ +@Single +class SuggestionProvider( + private val emoteRepository: EmoteRepository, + private val usersRepository: UsersRepository, + private val chatSettingsDataStore: ChatSettingsDataStore, +) { + + /** + * Get filtered suggestions for the given input text and channel. + * + * Returns a Flow that emits filtered suggestions whenever: + * - Input text changes + * - Available emotes/users/commands change + * - Preference for emote ordering changes + */ + fun getSuggestions( + inputText: String, + channel: UserName? + ): Flow> { + if (inputText.isBlank() || channel == null) { + return flowOf(emptyList()) + } + + // Extract the current word being typed + val currentWord = extractCurrentWord(inputText) + if (currentWord.isBlank()) { + return flowOf(emptyList()) + } + + return combine( + getEmoteSuggestions(channel, currentWord), + getUserSuggestions(channel, currentWord), + getCommandSuggestions(channel, currentWord), + chatSettingsDataStore.settings.map { it.preferEmoteSuggestions } + ) { emotes, users, commands, preferEmotes -> + // Order suggestions based on user preference + val orderedSuggestions = when { + preferEmotes -> emotes + users + commands + else -> users + emotes + commands + } + + // Limit results to reasonable number + orderedSuggestions.take(MAX_SUGGESTIONS) + } + } + + private fun getEmoteSuggestions(channel: UserName, constraint: String): Flow> { + return emoteRepository.getEmotes(channel).map { emotes -> + val suggestions = emotes.suggestions.map { Suggestion.EmoteSuggestion(it) } + filterEmotes(suggestions, constraint) + } + } + + private fun getUserSuggestions(channel: UserName, constraint: String): Flow> { + return usersRepository.getUsersFlow(channel).map { displayNameSet -> + val suggestions = displayNameSet.map { Suggestion.UserSuggestion(it) } + filterUsers(suggestions, constraint) + } + } + + private fun getCommandSuggestions(channel: UserName, constraint: String): Flow> { + // TODO: Implement actual command fetching from CommandRepository + // For now, return empty list + return flowOf(emptyList()) + } + + /** + * Extract the current word being typed from the full input text. + * Assumes space-separated words. + */ + private fun extractCurrentWord(text: String): String { + val cursorPos = text.length + val separator = ' ' + + // Find start of current word + var start = cursorPos + while (start > 0 && text[start - 1] != separator) start-- + + // Find end of current word + var end = cursorPos + while (end < text.length && text[end] != separator) end++ + + return text.substring(start, end) + } + + /** + * Filter emote suggestions by constraint. + * Prioritizes exact matches over case-insensitive matches. + */ + private fun filterEmotes( + suggestions: List, + constraint: String + ): List { + val exactMatches = suggestions.filter { it.emote.code.contains(constraint) } + val caseInsensitiveMatches = (suggestions - exactMatches.toSet()).filter { + it.emote.code.contains(constraint, ignoreCase = true) + } + return exactMatches + caseInsensitiveMatches + } + + /** + * Filter user suggestions by constraint. + * Handles @ prefix for mentions. + */ + private fun filterUsers( + suggestions: List, + constraint: String + ): List { + return suggestions.mapNotNull { suggestion -> + when { + constraint.startsWith('@') -> suggestion.copy(withLeadingAt = true) + else -> suggestion + }.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } + } + } + + /** + * Filter command suggestions by constraint. + */ + private fun filterCommands( + suggestions: List, + constraint: String + ): List { + return suggestions.filter { it.command.startsWith(constraint, ignoreCase = true) } + } + + companion object { + private const val MAX_SUGGESTIONS = 50 + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt new file mode 100644 index 000000000..787d02af4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -0,0 +1,73 @@ +package com.flxrs.dankchat.main.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +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.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class ChannelManagementViewModel( + private val preferenceStore: DankChatPreferenceStore, + private val channelDataCoordinator: ChannelDataCoordinator, + private val chatRepository: ChatRepository, +) : ViewModel() { + + val channels: StateFlow> = + preferenceStore.getChannelsWithRenamesFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + init { + // Set initial active channel if not already set + viewModelScope.launch { + if (chatRepository.activeChannel.value == null) { + val firstChannel = preferenceStore.channels.firstOrNull() + if (firstChannel != null) { + chatRepository.setActiveChannel(firstChannel) + } + } + } + + // Auto-load data when channels added + viewModelScope.launch { + channels.collect { channelList -> + channelList.forEach { channelWithRename -> + channelDataCoordinator.loadChannelData(channelWithRename.channel) + } + } + } + } + + fun addChannel(channel: UserName) { + val current = preferenceStore.channels + if (channel !in current) { + preferenceStore.channels = current + channel + chatRepository.setActiveChannel(channel) + } + } + + fun removeChannel(channel: UserName) { + preferenceStore.removeChannel(channel) + channelDataCoordinator.cleanupChannel(channel) + } + + 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() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt new file mode 100644 index 000000000..48a4059c5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -0,0 +1,42 @@ +package com.flxrs.dankchat.main.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class ChannelPagerViewModel( + private val chatRepository: ChatRepository, + private val preferenceStore: DankChatPreferenceStore, +) : ViewModel() { + + val uiState: StateFlow = combine( + preferenceStore.getChannelsWithRenamesFlow(), + chatRepository.activeChannel, + ) { channels, active -> + ChannelPagerUiState( + channels = channels.map { it.channel }, + currentPage = channels.indexOfFirst { it.channel == active } + .coerceAtLeast(0) + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelPagerUiState()) + + fun onPageChanged(page: Int) { + val channels = preferenceStore.channels + if (page in channels.indices) { + chatRepository.setActiveChannel(channels[page]) + } + } +} + +data class ChannelPagerUiState( + val channels: List = emptyList(), + val currentPage: Int = 0 +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt new file mode 100644 index 000000000..74f9c674a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt @@ -0,0 +1,71 @@ +package com.flxrs.dankchat.main.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class ChannelTabViewModel( + private val chatRepository: ChatRepository, + private val channelDataCoordinator: ChannelDataCoordinator, + private val preferenceStore: DankChatPreferenceStore, +) : ViewModel() { + + val uiState: StateFlow = combine( + preferenceStore.getChannelsWithRenamesFlow(), + chatRepository.activeChannel, + chatRepository.unreadMessagesMap, + chatRepository.channelMentionCount, + ) { channels, active, unread, mentions -> + ChannelTabUiState( + tabs = channels.map { 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 = channelDataCoordinator.getChannelLoadingState( + channelWithRename.channel + ).value + ) + }, + selectedIndex = channels.indexOfFirst { it.channel == active } + .coerceAtLeast(0) + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) + + fun selectTab(index: Int) { + val channels = preferenceStore.channels + if (index in channels.indices) { + val channel = channels[index] + chatRepository.setActiveChannel(channel) + chatRepository.clearUnreadMessage(channel) + chatRepository.clearMentionCount(channel) + } + } +} + +data class ChannelTabUiState( + val tabs: List = emptyList(), + val selectedIndex: Int = 0 +) + +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/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 4bf00c066..7d03ea72d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,8 +1,9 @@ package com.flxrs.dankchat.main.compose +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.EmojiEmotions @@ -13,19 +14,20 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.utils.compose.avoidRoundedCorners @Composable fun ChatInputLayout( - inputText: String, - onInputChange: (String) -> Unit, + textFieldState: TextFieldState, + canSend: Boolean, onSend: () -> Unit, onEmoteClick: () -> Unit, modifier: Modifier = Modifier ) { + // Input field with TextFieldState OutlinedTextField( + state = textFieldState, leadingIcon = { IconButton(onClick = onEmoteClick) { Icon( @@ -37,7 +39,7 @@ fun ChatInputLayout( trailingIcon = { IconButton( onClick = onSend, - enabled = inputText.isNotBlank() + enabled = canSend ) { Icon( imageVector = Icons.AutoMirrored.Filled.Send, @@ -45,16 +47,15 @@ fun ChatInputLayout( ) } }, - value = inputText, - onValueChange = onInputChange, modifier = modifier .fillMaxWidth() .avoidRoundedCorners(fallback = PaddingValues()), label = { - // TODO - Text(stringResource(R.string.hint_connected)) + Text(stringResource(R.string.send_hint)) }, - maxLines = 5, - singleLine = false + lineLimits = androidx.compose.foundation.text.input.TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = 5 + ) ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt new file mode 100644 index 000000000..9debb456b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -0,0 +1,110 @@ +package com.flxrs.dankchat.main.compose + +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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.suggestion.Suggestion +import com.flxrs.dankchat.chat.suggestion.SuggestionProvider +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel + +@OptIn(FlowPreview::class) +@KoinViewModel +class ChatInputViewModel( + private val chatRepository: ChatRepository, + private val suggestionProvider: SuggestionProvider, +) : ViewModel() { + + val textFieldState = TextFieldState() + + // Create flow from TextFieldState + private val textFlow = snapshotFlow { textFieldState.text.toString() } + + // Debounce text changes for suggestion lookups + private val debouncedText = textFlow.debounce(SUGGESTION_DEBOUNCE_MS) + + // Get suggestions based on current text and active channel + private val suggestions: StateFlow> = combine( + debouncedText, + chatRepository.activeChannel + ) { text, channel -> + text to channel + }.flatMapLatest { (text, channel) -> + suggestionProvider.getSuggestions(text, channel) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val uiState: StateFlow = combine( + textFlow, + suggestions, + chatRepository.activeChannel + ) { text, suggestions, activeChannel -> + ChatInputUiState( + text = text, + canSend = text.isNotBlank() && activeChannel != null, + suggestions = suggestions, + activeChannel = activeChannel + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) + + fun sendMessage() { + val text = textFieldState.text.toString() + val channel = uiState.value.activeChannel + if (text.isNotBlank() && channel != null) { + viewModelScope.launch { + chatRepository.sendMessage(channel.value, text) + textFieldState.clearText() + } + } + } + + fun clearText() { + textFieldState.clearText() + } + + /** + * 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 = currentText.length // Assume cursor at end for simplicity + val separator = ' ' + + // Find start of current word + var start = cursorPos + while (start > 0 && currentText[start - 1] != separator) start-- + + // Build new text with replacement + val replacement = suggestion.toString() + separator + val newText = currentText.substring(0, start) + replacement + + // Replace all text and place cursor at end + textFieldState.edit { + replace(0, length, newText) + placeCursorAtEnd() + } + } + + companion object { + private const val SUGGESTION_DEBOUNCE_MS = 20L + } +} + +data class ChatInputUiState( + val text: String = "", + val canSend: Boolean = false, + val suggestions: List = emptyList(), + val activeChannel: UserName? = null +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 4339fcd30..345108732 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -8,7 +8,10 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize 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.isImeVisible import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent @@ -31,7 +34,9 @@ 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.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.chat.compose.BadgeUi @@ -65,37 +70,44 @@ fun MainScreen( onAddChannel: () -> Unit, modifier: Modifier = Modifier ) { + // Scoped ViewModels - each handles one concern 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 channels by mainScreenViewModel.channels.collectAsStateWithLifecycle() - val activeChannel by mainScreenViewModel.activeChannel.collectAsStateWithLifecycle() - val activeChannelIndex by mainScreenViewModel.activeChannelIndex.collectAsStateWithLifecycle() - val unreadMessagesMap by mainScreenViewModel.unreadMessagesMap.collectAsStateWithLifecycle() - val channelMentionCount by mainScreenViewModel.channelMentionCount.collectAsStateWithLifecycle() - val totalMentionCount by mainScreenViewModel.totalMentionCount.collectAsStateWithLifecycle() + // Single state collection per ViewModel + val tabState by channelTabViewModel.uiState.collectAsStateWithLifecycle() + val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() + val inputState by chatInputViewModel.uiState.collectAsStateWithLifecycle() - // Simple local input state (will be replaced with ChatInputViewModel later) - var inputText by remember { mutableStateOf("") } - - val pagerState = rememberPagerState( - initialPage = activeChannelIndex, - pageCount = { channels.size } + val composePagerState = rememberPagerState( + initialPage = pagerState.currentPage, + pageCount = { pagerState.channels.size } ) val coroutineScope = rememberCoroutineScope() + val density = androidx.compose.ui.platform.LocalDensity.current + var inputHeight by remember { mutableStateOf(0.dp) } + + // Track keyboard visibility - hide immediately when closing animation starts + val imeAnimationTarget = WindowInsets.imeAnimationTarget + val isKeyboardVisible = WindowInsets.isImeVisible && + imeAnimationTarget.getBottom(density) > 0 // Target open = keyboard will be visible - // Sync pager with active channel - LaunchedEffect(activeChannelIndex) { - if (pagerState.currentPage != activeChannelIndex && activeChannelIndex < channels.size) { - pagerState.animateScrollToPage(activeChannelIndex) + // Sync Compose pager with ViewModel state + LaunchedEffect(pagerState.currentPage) { + if (composePagerState.currentPage != pagerState.currentPage && + pagerState.currentPage < pagerState.channels.size) { + composePagerState.animateScrollToPage(pagerState.currentPage) } } - // Update active channel when user swipes - LaunchedEffect(pagerState.currentPage) { - if (pagerState.currentPage < channels.size) { - val channel = channels[pagerState.currentPage].channel - mainScreenViewModel.setActiveChannel(channel) + // Update ViewModel when user swipes + LaunchedEffect(composePagerState.currentPage) { + if (composePagerState.currentPage != pagerState.currentPage) { + channelPagerViewModel.onPageChanged(composePagerState.currentPage) } } @@ -103,8 +115,8 @@ fun MainScreen( topBar = { MainAppBar( isLoggedIn = isLoggedIn, - hasChannels = channels.isNotEmpty(), - totalMentionCount = totalMentionCount, + hasChannels = tabState.tabs.isNotEmpty(), + totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, onAddChannel = onAddChannel, onOpenMentions = { sheetNavigationViewModel.openMentions() }, onLogin = onLogin, @@ -128,82 +140,89 @@ fun MainScreen( .fillMaxSize() .imePadding(), ) { paddingValues -> - if (channels.isEmpty()) { - EmptyStateContent( - isLoggedIn = isLoggedIn, - onAddChannel = onAddChannel, - onLogin = onLogin, - modifier = Modifier.padding(paddingValues) - ) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // Tabs - PrimaryScrollableTabRow( - selectedTabIndex = pagerState.currentPage, + Box(modifier = Modifier.fillMaxSize()) { + if (tabState.tabs.isEmpty()) { + EmptyStateContent( + isLoggedIn = isLoggedIn, + onAddChannel = onAddChannel, + onLogin = onLogin, + modifier = Modifier.padding(paddingValues) + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) ) { - channels.forEachIndexed { index, channelWithRename -> - val channel = channelWithRename.channel - val displayName = channelWithRename.rename?.value ?: channel.value - val hasUnread = unreadMessagesMap[channel] == true - val mentionCount = channelMentionCount[channel] ?: 0 - - Tab( - selected = index == pagerState.currentPage, - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - }, - text = { - BadgedBox( - badge = { - // TODO proper viewmodel state for this - if (mentionCount > 0) { - Badge() + // Tabs - Single state from ChannelTabViewModel + PrimaryScrollableTabRow( + selectedTabIndex = tabState.selectedIndex, + ) { + tabState.tabs.forEachIndexed { index, tab -> + Tab( + selected = tab.isSelected, + onClick = { + channelTabViewModel.selectTab(index) + coroutineScope.launch { + composePagerState.animateScrollToPage(index) + } + }, + text = { + BadgedBox( + badge = { + if (tab.mentionCount > 0) { + Badge { Text("${tab.mentionCount}") } + } } - }, - content = { - Text(text = channel.value) + ) { + Text(text = tab.displayName) } - ) - }, - ) + } + ) + } } - } - // Chat pager - HorizontalPager( - state = pagerState, - modifier = Modifier.weight(1f) - ) { page -> - if (page in channels.indices) { - val channel = channels[page].channel - ChatComposable( - channel = channel, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = { replyMessageId -> - sheetNavigationViewModel.openReplies(replyMessageId) - } - ) + // Chat pager - State from ChannelPagerViewModel + HorizontalPager( + state = composePagerState, + modifier = Modifier.weight(1f) + ) { page -> + if (page in pagerState.channels.indices) { + val channel = pagerState.channels[page] + ChatComposable( + channel = channel, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = { replyMessageId -> + sheetNavigationViewModel.openReplies(replyMessageId) + } + ) + } } + + // Input - State from ChatInputViewModel + ChatInputLayout( + textFieldState = chatInputViewModel.textFieldState, + canSend = inputState.canSend, + onSend = chatInputViewModel::sendMessage, + onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, + modifier = Modifier.onGloballyPositioned { coordinates -> + inputHeight = with(density) { coordinates.size.height.toDp() } + } + ) } + } - // Input at bottom - ChatInputLayout( - inputText = inputText, - onInputChange = { inputText = it }, - onSend = { - // TODO: Implement send via ChatRepository - inputText = "" - }, - onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, + // Suggestion dropdown floats above input field (only when keyboard visible) + if (isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, modifier = Modifier + .align(Alignment.BottomStart) + .padding(paddingValues) + .padding(bottom = inputHeight) ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index e5b3157ba..52f473285 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -1,113 +1,34 @@ package com.flxrs.dankchat.main.compose import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.channel.ChannelRepository -import com.flxrs.dankchat.data.repo.chat.ChatRepository -import com.flxrs.dankchat.data.state.ChannelLoadingState import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.domain.ChannelDataCoordinator -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.preferences.model.ChannelWithRename -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel +/** + * Minimal coordinator ViewModel for MainScreen. + * + * Individual components have their own ViewModels: + * - ChannelTabViewModel - Tab row state + * - ChannelPagerViewModel - Pager state + * - ChatInputViewModel - Input state + * - ChannelManagementViewModel - Channel operations + * + * This ViewModel only handles truly global concerns. + */ @KoinViewModel class MainScreenViewModel( - private val chatRepository: ChatRepository, - private val channelRepository: ChannelRepository, - private val preferenceStore: DankChatPreferenceStore, private val channelDataCoordinator: ChannelDataCoordinator, ) : ViewModel() { - val channels: StateFlow> = preferenceStore.getChannelsWithRenamesFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - val activeChannel: StateFlow = chatRepository.activeChannel - - val activeChannelIndex: StateFlow = combine(channels, activeChannel) { channels, active -> - channels.indexOfFirst { it.channel == active }.coerceAtLeast(0) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) - - val unreadMessagesMap: StateFlow> = - chatRepository.unreadMessagesMap - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) - - val channelMentionCount: StateFlow> = - chatRepository.channelMentionCount - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) - - val totalMentionCount: StateFlow = channelMentionCount.map { it.values.sum() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) - - // Global loading state - val globalLoadingState: StateFlow = channelDataCoordinator.globalLoadingState + // Only expose truly global state + val globalLoadingState: StateFlow = + channelDataCoordinator.globalLoadingState init { // Load global data once at startup channelDataCoordinator.loadGlobalData() - - // Observe channels and load data when added - viewModelScope.launch { - channels.collect { channelList -> - channelList.forEach { channelWithRename -> - channelDataCoordinator.loadChannelData(channelWithRename.channel) - } - } - } - } - - /** - * Get loading state for a specific channel - */ - fun getChannelLoadingState(channel: UserName): StateFlow { - return channelDataCoordinator.getChannelLoadingState(channel) - } - - fun setActiveChannel(channel: UserName) { - chatRepository.setActiveChannel(channel) - clearUnreadMessage(channel) - clearMentionCount(channel) - } - - fun addChannel(channel: UserName) { - val currentChannels = preferenceStore.channels - if (channel !in currentChannels) { - preferenceStore.channels = currentChannels + channel - // Data loading triggered automatically by channel observer - } - } - - fun removeChannel(channel: UserName) { - preferenceStore.removeChannel(channel) - channelDataCoordinator.cleanupChannel(channel) - } - - fun renameChannel(channel: UserName, displayName: String?) { - val rename = displayName?.ifBlank { null }?.let { UserName(it) } - preferenceStore.setRenamedChannel(ChannelWithRename(channel, rename)) - } - - fun clearUnreadMessage(channel: UserName) { - chatRepository.clearUnreadMessage(channel) - } - - fun clearMentionCount(channel: UserName) { - chatRepository.clearMentionCount(channel) - } - - fun retryChannelLoading(channel: UserName) { - channelDataCoordinator.loadChannelData(channel) - } - - fun reloadAllChannels() { - channelDataCoordinator.reloadAllChannels() } fun reloadGlobalData() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt new file mode 100644 index 000000000..b0bfe593f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt @@ -0,0 +1,117 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.flxrs.dankchat.chat.suggestion.Suggestion + +@Composable +fun SuggestionDropdown( + suggestions: List, + onSuggestionClick: (Suggestion) -> Unit, + modifier: Modifier = Modifier +) { + if (suggestions.isEmpty()) return + + OutlinedCard( + modifier = modifier + .padding(horizontal = 2.dp) + .fillMaxWidth(0.66f) + .heightIn(max = 250.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + ) { + items(suggestions, key = { it.toString() }) { suggestion -> + SuggestionItem( + suggestion = suggestion, + onClick = { onSuggestionClick(suggestion) }, + ) + } + } + } +} + +@Composable +private fun SuggestionItem( + suggestion: Suggestion, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon/Image based on suggestion type + when (suggestion) { + is Suggestion.EmoteSuggestion -> { + AsyncImage( + model = suggestion.emote.url, + contentDescription = suggestion.emote.code, + modifier = Modifier + .size(48.dp) + .padding(end = 12.dp) + ) + Text( + text = suggestion.emote.code, + style = MaterialTheme.typography.bodyLarge + ) + } + + is Suggestion.UserSuggestion -> { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "User", + modifier = Modifier + .size(32.dp) + .padding(end = 12.dp) + ) + Text( + text = suggestion.name.value, + style = MaterialTheme.typography.bodyLarge + ) + } + + is Suggestion.CommandSuggestion -> { + Icon( + imageVector = Icons.Default.Android, + contentDescription = "Command", + modifier = Modifier + .size(32.dp) + .padding(end = 12.dp) + ) + Text( + text = suggestion.command, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} From 225742304dfdf2ff22015381bf6c3d1f9eaf9fbc Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 006/349] feat(settings): Migrate settings screens to Compose navigation --- .../com/flxrs/dankchat/DankChatViewModel.kt | 23 + .../dankchat/changelog/ChangelogScreen.kt | 78 +++ .../chat/compose/ChatMessageMapper.kt | 1 - .../dankchat/chat/compose/Linkification.kt | 60 +++ .../chat/compose/messages/PrivMessage.kt | 31 +- .../compose/messages/WhisperAndRedemption.kt | 30 +- .../messages/common/MessageTextBuilders.kt | 2 +- .../chat/suggestion/SuggestionProvider.kt | 3 +- .../chat/user/UserPopupComposeViewModel.kt | 112 +++++ .../chat/user/UserPopupStateParams.kt | 18 + .../chat/user/compose/UserPopupDialog.kt | 248 ++++++++++ .../com/flxrs/dankchat/main/MainActivity.kt | 242 +++++++--- .../main/compose/ChannelPagerViewModel.kt | 5 +- .../flxrs/dankchat/main/compose/ChannelTab.kt | 41 ++ .../dankchat/main/compose/ChannelTabRow.kt | 30 ++ .../main/compose/ChannelTabViewModel.kt | 9 +- .../dankchat/main/compose/ChatInputLayout.kt | 3 +- .../main/compose/ChatInputViewModel.kt | 16 +- .../main/compose/EmptyStateContent.kt | 71 ++- .../flxrs/dankchat/main/compose/MainAppBar.kt | 106 ++--- .../flxrs/dankchat/main/compose/MainScreen.kt | 138 ++++-- .../main/compose/SuggestionDropdown.kt | 68 ++- .../main/compose/dialogs/AddChannelDialog.kt | 71 +++ .../preferences/about/AboutFragment.kt | 72 +-- .../dankchat/preferences/about/AboutScreen.kt | 120 +++++ .../appearance/AppearanceSettingsFragment.kt | 244 +--------- .../appearance/AppearanceSettingsScreen.kt | 313 ++++++++++++ .../developer/DeveloperSettingsFragment.kt | 353 +------------- .../developer/DeveloperSettingsScreen.kt | 448 ++++++++++++++++++ .../overview/OverviewSettingsFragment.kt | 122 +---- .../overview/OverviewSettingsScreen.kt | 169 +++++++ .../stream/StreamsSettingsFragment.kt | 83 +--- .../stream/StreamsSettingsScreen.kt | 122 +++++ .../utils/compose/RoundedCornerPadding.kt | 5 + 34 files changed, 2352 insertions(+), 1105 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupComposeViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupStateParams.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 13f2c5d74..63df75c00 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -21,6 +21,12 @@ import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds +import android.webkit.CookieManager +import android.webkit.WebStorage +import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository + @KoinViewModel class DankChatViewModel( private val chatRepository: ChatRepository, @@ -28,6 +34,9 @@ class DankChatViewModel( private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val authApiClient: AuthApiClient, private val dataRepository: DataRepository, + private val ignoresRepository: IgnoresRepository, + private val userStateRepository: UserStateRepository, + private val emoteUsageRepository: EmoteUsageRepository, ) : ViewModel() { val serviceEvents = dataRepository.serviceEvents @@ -68,6 +77,20 @@ class DankChatViewModel( } } + fun clearDataForLogout() { + CookieManager.getInstance().removeAllCookies(null) + WebStorage.getInstance().deleteAllData() + + dankChatPreferenceStore.clearLogin() + userStateRepository.clear() + + chatRepository.closeAndReconnect() + ignoresRepository.clearIgnores() + viewModelScope.launch { + emoteUsageRepository.clearUsages() + } + } + private suspend fun validateUser() { // no token = nothing to validate 4head val token = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt new file mode 100644 index 000000000..417a6d55d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt @@ -0,0 +1,78 @@ +package com.flxrs.dankchat.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( + onBackPressed: () -> 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 = onBackPressed, + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index c897fe984..7665040e1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -326,7 +326,6 @@ object ChatMessageMapper { emotes = emoteGroup ) } - Log.d("XXX", "emotes: $emoteUis") val threadUi = if (thread != null && !isInReplies) { thread.toThreadUi() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt new file mode 100644 index 000000000..edc2b4310 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt @@ -0,0 +1,60 @@ +package com.flxrs.dankchat.chat.compose + +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 +import android.util.Patterns + +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() + + // Skip partial matches (preceded by non-whitespace) + // Check character before match in the original text or the previousChar if at start + val prevChar = if (start > 0) text[start - 1] else previousChar + if (prevChar != null && !prevChar.isWhitespace()) { + continue + } + + // Extend URL logic from ChatAdapter + // Find the actual end of the URL by continuing until whitespace or disallowed char + var fixedEnd = end + while (fixedEnd < text.length) { + val c = text[fixedEnd] + if (c.isWhitespace() || c in DISALLOWED_URL_CHARS) { + break + } + fixedEnd++ + } + end = fixedEnd + + val url = text.substring(start, end) + + // Append text before URL + if (start > lastIndex) { + append(text.substring(lastIndex, start)) + } + + // Append URL with annotation and style + pushStringAnnotation(tag = "URL", annotation = url) + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { + append(url) + } + pop() + + lastIndex = end + } + + // Append remaining text + if (lastIndex < text.length) { + append(text.substring(lastIndex)) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index d240969a7..5b99b4243 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -1,5 +1,7 @@ package com.flxrs.dankchat.chat.compose.messages +import android.util.Log +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.indication @@ -14,6 +16,7 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Reply import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable @@ -30,12 +33,14 @@ 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 androidx.core.net.toUri import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.StackedEmote import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent +import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator @@ -129,9 +134,10 @@ private fun PrivMessageText( val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val nameColor = rememberBackgroundColor(message.lightNameColor, message.darkNameColor) + val linkColor = MaterialTheme.colorScheme.primary // Build annotated string with text content - val annotatedString = remember(message, defaultTextColor, nameColor, showChannelPrefix) { + val annotatedString = remember(message, defaultTextColor, nameColor, showChannelPrefix, linkColor) { buildAnnotatedString { // Channel prefix (for mention tab) if (showChannelPrefix) { @@ -196,11 +202,13 @@ private fun PrivMessageText( message.emotes.sortedBy { it.position.first }.forEach { emote -> // Text before emote if (currentPos < emote.position.first) { - append(message.message.substring(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}]") + appendInlineContent("EMOTE_${emote.code}", emote.code) // Add space after emote if next character exists and is not whitespace val nextPos = emote.position.last + 1 @@ -213,7 +221,9 @@ private fun PrivMessageText( // Remaining text if (currentPos < message.message.length) { - append(message.message.substring(currentPos)) + val segment = message.message.substring(currentPos) + val prevChar = if (currentPos > 0) message.message[currentPos - 1] else null + appendWithLinks(segment, linkColor, prevChar) } } } @@ -269,6 +279,19 @@ private fun PrivMessageText( onUserClick(userId, userName, displayName, channel, message.badges, false) } } + + // Handle URL clicks + annotatedString.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, annotation.item.toUri()) + } catch (e: Exception) { + Log.e("PrivMessage", "Error launching URL", e) + } + } }, onTextLongClick = { offset -> // Handle username long-press diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 14a84473b..32d2c523b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -1,5 +1,7 @@ package com.flxrs.dankchat.chat.compose.messages +import android.util.Log +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource @@ -26,6 +28,7 @@ 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 androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi @@ -33,6 +36,7 @@ import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.StackedEmote import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent +import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator @@ -89,9 +93,10 @@ private fun WhisperMessageText( val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val senderColor = rememberBackgroundColor(message.lightSenderColor, message.darkSenderColor) val recipientColor = rememberBackgroundColor(message.lightRecipientColor, message.darkRecipientColor) + val linkColor = MaterialTheme.colorScheme.primary // Build annotated string with text content - val annotatedString = remember(message, defaultTextColor, senderColor, recipientColor) { + val annotatedString = remember(message, defaultTextColor, senderColor, recipientColor, linkColor) { buildAnnotatedString { // Timestamp if (message.timestamp.isNotEmpty()) { @@ -153,11 +158,13 @@ private fun WhisperMessageText( message.emotes.sortedBy { it.position.first }.forEach { emote -> // Text before emote if (currentPos < emote.position.first) { - append(message.message.substring(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}]") + appendInlineContent("EMOTE_${emote.code}", emote.code) // Add space after emote if next character exists and is not whitespace val nextPos = emote.position.last + 1 @@ -170,7 +177,9 @@ private fun WhisperMessageText( // Remaining text if (currentPos < message.message.length) { - append(message.message.substring(currentPos)) + val segment = message.message.substring(currentPos) + val prevChar = if (currentPos > 0) message.message[currentPos - 1] else null + appendWithLinks(segment, linkColor, prevChar) } } } @@ -224,6 +233,19 @@ private fun WhisperMessageText( onUserClick(userId, userName, displayName, message.badges, false) } } + + // Handle URL clicks + annotatedString.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, annotation.item.toUri()) + } catch (e: Exception) { + Log.e("WhisperMessage", "Error launching URL", e) + } + } }, onTextLongClick = { offset -> // Handle username long-press diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt index 6bb7aa155..59957e5be 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt @@ -71,7 +71,7 @@ fun AnnotatedString.Builder.appendMessageWithEmotes( } // Emote inline content - appendInlineContent("EMOTE_${emote.code}", "[${emote.code}]") + appendInlineContent("EMOTE_${emote.code}", emote.code) // Add space after emote if next character exists and is not whitespace val nextPos = emote.position.last + 1 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt index 8e79ff830..f8a149b84 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt @@ -39,7 +39,7 @@ class SuggestionProvider( // Extract the current word being typed val currentWord = extractCurrentWord(inputText) - if (currentWord.isBlank()) { + if (currentWord.isBlank() || currentWord.length < MIN_SUGGESTION_CHARS) { return flowOf(emptyList()) } @@ -142,5 +142,6 @@ class SuggestionProvider( companion object { private const val MAX_SUGGESTIONS = 50 + private const val MIN_SUGGESTION_CHARS = 2 } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupComposeViewModel.kt new file mode 100644 index 000000000..3c5182235 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupComposeViewModel.kt @@ -0,0 +1,112 @@ +package com.flxrs.dankchat.chat.user + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.helix.dto.UserDto +import com.flxrs.dankchat.data.api.helix.dto.UserFollowsDto +import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.utils.DateTimeUtils.asParsedZonedDateTime +import kotlinx.coroutines.async +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 + +@KoinViewModel +class UserPopupComposeViewModel( + @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 _userPopupState = MutableStateFlow(UserPopupState.Loading(params.targetUserName, params.targetDisplayName)) + val userPopupState: StateFlow = _userPopupState.asStateFlow() + + init { + loadData() + } + + fun blockUser() = updateStateWith { targetUserId, targetUsername -> + ignoresRepository.addUserBlock(targetUserId, targetUsername) + } + + fun unblockUser() = updateStateWith { targetUserId, targetUsername -> + ignoresRepository.removeUserBlock(targetUserId, targetUsername) + } + + private inline fun updateStateWith(crossinline block: suspend (targetUserId: UserId, targetUsername: UserName) -> Unit) = viewModelScope.launch { + if (!preferenceStore.isLoggedIn) { + return@launch + } + + val result = runCatching { block(params.targetUserId, params.targetUserName) } + when { + result.isFailure -> _userPopupState.value = UserPopupState.Error(result.exceptionOrNull()) + else -> loadData() + } + } + + private fun loadData() = viewModelScope.launch { + if (!preferenceStore.isLoggedIn) { + _userPopupState.value = UserPopupState.NotLoggedIn(params.targetUserName, params.targetDisplayName) + return@launch + } + + _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 = 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, + ) + } + + val state = result.getOrElse { UserPopupState.Error(it) } + _userPopupState.value = state + } + + private fun mapToState(user: UserDto?, showFollowing: Boolean, channelUserFollows: UserFollowsDto?, isBlocked: Boolean): UserPopupState { + user ?: return UserPopupState.Error() + + return UserPopupState.Success( + userId = user.id, + userName = user.name, + displayName = user.displayName, + avatarUrl = user.avatarUrl, + created = user.createdAt.asParsedZonedDateTime(), + showFollowingSince = showFollowing, + followingSince = channelUserFollows?.data?.firstOrNull()?.followedAt?.asParsedZonedDateTime(), + isBlocked = isBlocked + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupStateParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupStateParams.kt new file mode 100644 index 000000000..ac1171de1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupStateParams.kt @@ -0,0 +1,18 @@ +package com.flxrs.dankchat.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/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt new file mode 100644 index 000000000..25aefc040 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -0,0 +1,248 @@ +package com.flxrs.dankchat.chat.user.compose + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Launch +import androidx.compose.material.icons.filled.Message +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +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.Text +import androidx.compose.material3.TextButton +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel +import com.flxrs.dankchat.chat.user.UserPopupState +import com.flxrs.dankchat.chat.user.UserPopupStateParams +import com.flxrs.dankchat.data.twitch.badge.Badge +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserPopupDialog( + params: UserPopupStateParams, + onDismiss: () -> Unit, + onMention: (String, String) -> Unit, + onWhisper: (String) -> Unit, + onOpenChannel: (String) -> Unit, + onReport: (String) -> Unit, +) { + val viewModel: UserPopupComposeViewModel = koinViewModel( + parameters = { parametersOf(params) } + ) + val state by viewModel.userPopupState.collectAsStateWithLifecycle() + var showBlockConfirmation by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (val s = state) { + is UserPopupState.Loading -> { + CircularProgressIndicator() + Text( + text = s.userName.formatWithDisplayName(s.displayName), + style = MaterialTheme.typography.titleLarge + ) + } + is UserPopupState.NotLoggedIn -> { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + Text( + text = s.userName.formatWithDisplayName(s.displayName), + style = MaterialTheme.typography.titleLarge + ) + } + is UserPopupState.Error -> { + Text("Error: ${s.throwable?.message}") + } + is UserPopupState.Success -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = s.avatarUrl, + contentDescription = null, + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .clickable { onOpenChannel(s.userName.value) } + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = s.userName.formatWithDisplayName(s.displayName), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.user_popup_created, s.created), + style = MaterialTheme.typography.bodyMedium + ) + if (s.showFollowingSince) { + Text( + text = s.followingSince?.let { + stringResource(R.string.user_popup_following_since, it) + } ?: stringResource(R.string.user_popup_not_following), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + if (params.badges.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + items(params.badges) { badge -> + AsyncImage( + model = badge.url, + contentDescription = badge.title, + modifier = Modifier.size(32.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ActionIconButton( + icon = Icons.Default.Chat, + label = stringResource(R.string.user_popup_mention), + onClick = { + onMention(s.userName.value, s.displayName.value) + onDismiss() + } + ) + ActionIconButton( + icon = Icons.Default.Message, + label = stringResource(R.string.user_popup_whisper), + onClick = { + onWhisper(s.userName.value) + onDismiss() + } + ) + ActionIconButton( + icon = Icons.Default.Block, + label = if (s.isBlocked) stringResource(R.string.user_popup_unblock) else stringResource(R.string.user_popup_block), + onClick = { + if (s.isBlocked) { + viewModel.unblockUser() + } else { + showBlockConfirmation = true + } + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ActionIconButton( + icon = Icons.Default.Launch, + label = stringResource(R.string.open_channel), + onClick = { onOpenChannel(s.userName.value) } + ) + ActionIconButton( + icon = Icons.Default.Flag, + label = stringResource(R.string.user_popup_report), + onClick = { onReport(s.userName.value) } + ) + } + } + } + } + } + + if (showBlockConfirmation) { + AlertDialog( + onDismissRequest = { showBlockConfirmation = false }, + title = { Text(stringResource(R.string.confirm_user_block_title)) }, + text = { Text(stringResource(R.string.confirm_user_block_message)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.blockUser() + showBlockConfirmation = false + } + ) { + Text(stringResource(R.string.confirm_user_block_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showBlockConfirmation = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } +} + +@Composable +private fun ActionIconButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable(onClick = onClick).padding(8.dp) + ) { + Icon(imageVector = icon, contentDescription = null) + Text(text = label, style = MaterialTheme.typography.labelSmall) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 9e8efb0f0..d52af07a0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -25,6 +25,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import androidx.navigation.findNavController import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R @@ -33,7 +36,22 @@ 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.main.compose.MainScreen +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.about.AboutScreen +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsScreen +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.DeveloperSettingsDataStore +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.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.theme.DankChatTheme import com.flxrs.dankchat.utils.extensions.hasPermission import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu @@ -51,7 +69,7 @@ class MainActivity : AppCompatActivity() { private val viewModel: DankChatViewModel by viewModel() private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() - private val dankChatPreferenceStore: com.flxrs.dankchat.preferences.DankChatPreferenceStore by inject() + private val dankChatPreferenceStore: DankChatPreferenceStore by inject() private val pendingChannelsToClear = mutableListOf() private var navController: NavController? = null private var bindingRef: MainActivityBinding? = null @@ -92,9 +110,10 @@ class MainActivity : AppCompatActivity() { else -> DynamicColors.applyToActivityIfAvailable(this) } - super.onCreate(savedInstanceState) enableEdgeToEdge() + super.onCreate(savedInstanceState) + // Check if we should use Compose UI val useComposeUi = developerSettingsDataStore.current().useComposeChatUi @@ -133,70 +152,171 @@ class MainActivity : AppCompatActivity() { private fun setupComposeUi() { setContent { DankChatTheme { + val navController = rememberNavController() val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle( initialValue = developerSettingsDataStore.current() ) - MainScreen( - isLoggedIn = isLoggedIn, - onNavigateToSettings = { - // TODO: Navigate to settings (need to implement Compose settings or use dialog) - }, - onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> - // TODO: Show user popup dialog - }, - onMessageLongClick = { messageId, channel, fullMessage -> - // TODO: Show message options dialog - }, - onEmoteClick = { emotes -> - // TODO: Show emote overlay/fullscreen - }, - onLogin = { - // TODO: Navigate to login - }, - onRelogin = { - // TODO: Navigate to login - }, - onLogout = { - // TODO: Show logout confirmation - }, - onManageChannels = { - // TODO: Show manage channels dialog - }, - onOpenChannel = { - // TODO: Open channel in browser - }, - onRemoveChannel = { - // TODO: Remove active channel - }, - onReportChannel = { - // TODO: Report channel - }, - onBlockChannel = { - // TODO: Block channel - }, - onReloadEmotes = { - // TODO: Reload emotes - }, - onReconnect = { - // TODO: Reconnect to chat - }, - onClearChat = { - // TODO: Clear chat messages - }, - onCaptureImage = { - // TODO: Capture image - }, - onCaptureVideo = { - // TODO: Capture video - }, - onChooseMedia = { - // TODO: Choose media - }, - onAddChannel = { - // TODO: Show add channel dialog + NavHost(navController = navController, startDestination = "main") { + composable("main") { + MainScreen( + isLoggedIn = isLoggedIn, + onNavigateToSettings = { + navController.navigate("settings") + }, + onMessageLongClick = { messageId, channel, fullMessage -> + // TODO: Show message options dialog + }, + onEmoteClick = { emotes -> + // TODO: Show emote overlay/fullscreen + }, + onLogin = { + // TODO: Navigate to login + }, + onRelogin = { + // TODO: Navigate to login + }, + onLogout = { + // TODO: Show logout confirmation + }, + onManageChannels = { + // TODO: Show manage channels dialog + }, + onOpenChannel = { + // TODO: Open channel in browser + }, + onRemoveChannel = { + // TODO: Remove active channel + }, + onReportChannel = { + // TODO: Report channel + }, + onBlockChannel = { + // TODO: Block channel + }, + onReloadEmotes = { + // TODO: Reload emotes + }, + onReconnect = { + // TODO: Reconnect to chat + }, + onClearChat = { + // TODO: Clear chat messages + }, + onCaptureImage = { + // TODO: Capture image + }, + onCaptureVideo = { + // TODO: Capture video + }, + onChooseMedia = { + // TODO: Choose media + }, + onAddChannel = { + // TODO: Show add channel dialog + } + ) } - ) + composable("settings") { + OverviewSettingsScreen( + isLoggedIn = isLoggedIn, + hasChangelog = com.flxrs.dankchat.changelog.DankChatVersion.HAS_CHANGELOG, + onBackPressed = { navController.popBackStack() }, + onLogoutRequested = { + viewModel.clearDataForLogout() + navController.popBackStack() + }, + onNavigateRequested = { destinationId -> + when (destinationId) { + R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment -> navController.navigate("appearance") + R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment -> navController.navigate("notifications") + R.id.action_overviewSettingsFragment_to_chatSettingsFragment -> navController.navigate("chat") + R.id.action_overviewSettingsFragment_to_streamsSettingsFragment -> navController.navigate("streams") + R.id.action_overviewSettingsFragment_to_toolsSettingsFragment -> navController.navigate("tools") + R.id.action_overviewSettingsFragment_to_developerSettingsFragment -> navController.navigate("developer") + R.id.action_overviewSettingsFragment_to_changelogSheetFragment -> navController.navigate("changelog") + R.id.action_overviewSettingsFragment_to_aboutFragment -> navController.navigate("about") + } + } + ) + } + composable("appearance") { + AppearanceSettingsScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable("notifications") { + NotificationsSettingsScreen( + onNavToHighlights = { navController.navigate("highlights") }, + onNavToIgnores = { navController.navigate("ignores") }, + onNavBack = { navController.popBackStack() } + ) + } + composable("highlights") { + HighlightsScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable("ignores") { + IgnoresScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable("chat") { + ChatSettingsScreen( + onNavToCommands = { navController.navigate("commands") }, + onNavToUserDisplays = { navController.navigate("user_display") }, + onNavBack = { navController.popBackStack() } + ) + } + composable("commands") { + CustomCommandsScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable("user_display") { + UserDisplayScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable("streams") { + StreamsSettingsScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable("tools") { + ToolsSettingsScreen( + onNavToImageUploader = { navController.navigate("image_uploader") }, + onNavToTTSUserIgnoreList = { navController.navigate("tts_ignore_list") }, + onNavBack = { navController.popBackStack() } + ) + } + composable("image_uploader") { + ImageUploaderScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable("tts_ignore_list") { + TTSUserIgnoreListScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable("developer") { + DeveloperSettingsScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable("changelog") { + com.flxrs.dankchat.changelog.ChangelogScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable("about") { + AboutScreen( + onBackPressed = { navController.popBackStack() } + ) + } + } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt index 48a4059c5..38bb8bb61 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -31,7 +31,10 @@ class ChannelPagerViewModel( fun onPageChanged(page: Int) { val channels = preferenceStore.channels if (page in channels.indices) { - chatRepository.setActiveChannel(channels[page]) + val channel = channels[page] + chatRepository.setActiveChannel(channel) + chatRepository.clearUnreadMessage(channel) + chatRepository.clearMentionCount(channel) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt new file mode 100644 index 000000000..046e7c142 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt @@ -0,0 +1,41 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun ChannelTab( + tab: ChannelTabItem, + onClick: () -> Unit +) { + val tabColor = when { + tab.isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 -> MaterialTheme.colorScheme.error + tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant // TODO maybe layer with alpha? + } + + Tab( + selected = tab.isSelected, + onClick = onClick, + text = { + BadgedBox( + badge = { + if (tab.mentionCount > 0) { + // TODO could add mention count as text + Badge() + } + } + ) { + Text( + text = tab.displayName, + color = tabColor + ) + } + } + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt new file mode 100644 index 000000000..ce34da923 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt @@ -0,0 +1,30 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ChannelTabRow( + tabs: List, + selectedIndex: Int, + onTabSelected: (Int) -> Unit +) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedIndex, + ) { + tabs.forEachIndexed { index, tab -> + ChannelTab( + tab = tab, + onClick = { + onTabSelected(index) + } + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt index 74f9c674a..e0e828eb4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt @@ -40,8 +40,10 @@ class ChannelTabViewModel( ).value ) }, - selectedIndex = channels.indexOfFirst { it.channel == active } - .coerceAtLeast(0) + selectedIndex = channels + .indexOfFirst { it.channel == active } + .coerceAtLeast(0), + loading = false, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) @@ -58,7 +60,8 @@ class ChannelTabViewModel( data class ChannelTabUiState( val tabs: List = emptyList(), - val selectedIndex: Int = 0 + val selectedIndex: Int = 0, + val loading: Boolean = true, ) data class ChannelTabItem( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 7d03ea72d..2ecbe1591 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.main.compose -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.input.TextFieldState @@ -9,6 +8,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.EmojiEmotions 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 @@ -27,6 +27,7 @@ fun ChatInputLayout( ) { // Input field with TextFieldState OutlinedTextField( + shape = MaterialTheme.shapes.extraLarge, state = textFieldState, leadingIcon = { IconButton(onClick = onEmoteClick) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 9debb456b..d7d97f92f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -69,7 +69,21 @@ class ChatInputViewModel( } } - fun clearText() { + fun insertText(text: String) { + textFieldState.edit { + append(text) + placeCursorAtEnd() + } + } + + fun updateInputText(text: String) { + textFieldState.edit { + replace(0, length, text) + placeCursorAtEnd() + } + } + + fun clearInput() { textFieldState.clearText() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt index 3cf76c661..043983b59 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt @@ -1,20 +1,20 @@ package com.flxrs.dankchat.main.compose 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Login import androidx.compose.material3.AssistChip 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 @@ -22,7 +22,6 @@ 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.preferences.components.DankBackground @OptIn(ExperimentalLayoutApi::class) @Composable @@ -30,61 +29,59 @@ fun EmptyStateContent( isLoggedIn: Boolean, onAddChannel: () -> Unit, onLogin: () -> Unit, + onToggleAppBar: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, modifier: Modifier = Modifier ) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - DankBackground(visible = true) + Surface(modifier = modifier) { Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(32.dp) + verticalArrangement = Arrangement.Center ) { Text( - text = stringResource(R.string.no_channels_added), + text = stringResource(R.string.no_channels_added), // You might need to add this string or use a literal/different string style = MaterialTheme.typography.headlineMedium ) - + Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.add_channel_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Quick action chips + + // Shortcut chips FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) ) { AssistChip( onClick = onAddChannel, label = { Text(stringResource(R.string.add_channel)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null - ) - } + leadingIcon = { Icon(Icons.Default.Add, null) } ) - + if (!isLoggedIn) { AssistChip( onClick = onLogin, label = { Text(stringResource(R.string.login)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Login, - contentDescription = null - ) - } + leadingIcon = { Icon(Icons.AutoMirrored.Filled.Login, null) } ) } + + AssistChip( + onClick = onToggleAppBar, + label = { Text("Toggle App Bar") } // Consider using resources + ) + + AssistChip( + onClick = onToggleFullscreen, + label = { Text("Toggle Fullscreen") } + ) + + AssistChip( + onClick = onToggleInput, + label = { Text("Toggle Input") } + ) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index adf0a28fb..3b152a59d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -9,6 +9,8 @@ import androidx.compose.material3.DropdownMenuItem 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.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -24,7 +26,6 @@ import com.flxrs.dankchat.R @Composable fun MainAppBar( isLoggedIn: Boolean, - hasChannels: Boolean, totalMentionCount: Int, onAddChannel: () -> Unit, onOpenMentions: () -> Unit, @@ -62,15 +63,16 @@ fun MainAppBar( ) } - // Mentions button (visible if channels exist) - if (hasChannels) { - IconButton(onClick = onOpenMentions) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.mentions_title) - ) - // TODO: Apply color tint for mentions indicator - } + IconButton(onClick = onOpenMentions) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.mentions_title), + tint = if (totalMentionCount > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + ) } // Overflow menu @@ -101,7 +103,7 @@ fun MainAppBar( showAccountMenu = true } ) - + DropdownMenu( expanded = showAccountMenu, onDismissRequest = { showAccountMenu = false } @@ -126,61 +128,59 @@ fun MainAppBar( } // Manage channels - if (hasChannels) { + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_channels)) }, + onClick = { + onManageChannels() + showMenu = false + } + ) + + // Channel submenu + DropdownMenuItem( + text = { Text(stringResource(R.string.channel)) }, + onClick = { + showChannelMenu = true + } + ) + + DropdownMenu( + expanded = showChannelMenu, + onDismissRequest = { showChannelMenu = false } + ) { DropdownMenuItem( - text = { Text(stringResource(R.string.manage_channels)) }, + text = { Text(stringResource(R.string.open_channel)) }, onClick = { - onManageChannels() + onOpenChannel() + showChannelMenu = false showMenu = false } ) - - // Channel submenu DropdownMenuItem( - text = { Text(stringResource(R.string.channel)) }, + text = { Text(stringResource(R.string.remove_channel)) }, onClick = { - showChannelMenu = true + onRemoveChannel() + showChannelMenu = false + showMenu = false } ) - - DropdownMenu( - expanded = showChannelMenu, - onDismissRequest = { showChannelMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.open_channel)) }, - onClick = { - onOpenChannel() - showChannelMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_channel)) }, - onClick = { - onRemoveChannel() - showChannelMenu = false - showMenu = false - } - ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_channel)) }, + onClick = { + onReportChannel() + showChannelMenu = false + showMenu = false + } + ) + if (isLoggedIn) { DropdownMenuItem( - text = { Text(stringResource(R.string.report_channel)) }, + text = { Text(stringResource(R.string.block_channel)) }, onClick = { - onReportChannel() + onBlockChannel() showChannelMenu = false showMenu = false } ) - if (isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_channel)) }, - onClick = { - onBlockChannel() - showChannelMenu = false - showMenu = false - } - ) - } } } @@ -191,7 +191,7 @@ fun MainAppBar( showUploadMenu = true } ) - + DropdownMenu( expanded = showUploadMenu, onDismissRequest = { showUploadMenu = false } @@ -229,7 +229,7 @@ fun MainAppBar( showMoreMenu = true } ) - + DropdownMenu( expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 345108732..7c242cebd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -4,29 +4,15 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -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.isImeVisible -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.PrimaryScrollableTabRow -import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold -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 @@ -37,20 +23,31 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatComposable import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.components.DankBackground +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel +import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog + +import com.flxrs.dankchat.chat.user.compose.UserPopupDialog +import com.flxrs.dankchat.chat.user.UserPopupStateParams +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.DisplayName + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun MainScreen( isLoggedIn: Boolean, onNavigateToSettings: () -> 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, onLogin: () -> Unit, @@ -77,9 +74,40 @@ fun MainScreen( val channelPagerViewModel: ChannelPagerViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val scope = rememberCoroutineScope() + + var showAddChannelDialog by remember { mutableStateOf(false) } + var userPopupParams by remember { mutableStateOf(null) } + + if (showAddChannelDialog) { + AddChannelDialog( + onDismiss = { showAddChannelDialog = false }, + onAddChannel = { + channelManagementViewModel.addChannel(it) + showAddChannelDialog = false + } + ) + } - // Single state collection per ViewModel - val tabState by channelTabViewModel.uiState.collectAsStateWithLifecycle() + if (userPopupParams != null) { + UserPopupDialog( + params = userPopupParams!!, + onDismiss = { userPopupParams = null }, + onMention = { name, _ -> + chatInputViewModel.insertText("@$name ") + }, + onWhisper = { name -> + sheetNavigationViewModel.openWhispers() + chatInputViewModel.updateInputText("/w $name ") + }, + onOpenChannel = { _ -> onOpenChannel() }, + onReport = { _ -> + onReportChannel() + } + ) + } + + val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() val inputState by chatInputViewModel.uiState.collectAsStateWithLifecycle() @@ -87,19 +115,26 @@ fun MainScreen( initialPage = pagerState.currentPage, pageCount = { pagerState.channels.size } ) - val coroutineScope = rememberCoroutineScope() - val density = androidx.compose.ui.platform.LocalDensity.current + val density = LocalDensity.current var inputHeight by remember { mutableStateOf(0.dp) } // Track keyboard visibility - hide immediately when closing animation starts + val focusManager = LocalFocusManager.current val imeAnimationTarget = WindowInsets.imeAnimationTarget val isKeyboardVisible = WindowInsets.isImeVisible && - imeAnimationTarget.getBottom(density) > 0 // Target open = keyboard will be visible + imeAnimationTarget.getBottom(density) > 0 // Target open = keyboard will be visible + + LaunchedEffect(isKeyboardVisible) { + if (!isKeyboardVisible) { + focusManager.clearFocus() + } + } // Sync Compose pager with ViewModel state LaunchedEffect(pagerState.currentPage) { if (composePagerState.currentPage != pagerState.currentPage && - pagerState.currentPage < pagerState.channels.size) { + pagerState.currentPage < pagerState.channels.size + ) { composePagerState.animateScrollToPage(pagerState.currentPage) } } @@ -113,11 +148,14 @@ fun MainScreen( Scaffold( topBar = { + if (tabState.tabs.isEmpty()) { + return@Scaffold + } + MainAppBar( isLoggedIn = isLoggedIn, - hasChannels = tabState.tabs.isNotEmpty(), totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onAddChannel = onAddChannel, + onAddChannel = { showAddChannelDialog = true }, onOpenMentions = { sheetNavigationViewModel.openMentions() }, onLogin = onLogin, onRelogin = onRelogin, @@ -141,11 +179,18 @@ fun MainScreen( .imePadding(), ) { paddingValues -> Box(modifier = Modifier.fillMaxSize()) { + DankBackground(visible = tabState.loading) + if (tabState.loading) { + return@Scaffold + } if (tabState.tabs.isEmpty()) { EmptyStateContent( isLoggedIn = isLoggedIn, - onAddChannel = onAddChannel, + onAddChannel = { showAddChannelDialog = true }, onLogin = onLogin, + onToggleAppBar = { /* TODO */ }, + onToggleFullscreen = { /* TODO */ }, + onToggleInput = { /* TODO */ }, modifier = Modifier.padding(paddingValues) ) } else { @@ -155,32 +200,16 @@ fun MainScreen( .padding(paddingValues) ) { // Tabs - Single state from ChannelTabViewModel - PrimaryScrollableTabRow( - selectedTabIndex = tabState.selectedIndex, - ) { - tabState.tabs.forEachIndexed { index, tab -> - Tab( - selected = tab.isSelected, - onClick = { - channelTabViewModel.selectTab(index) - coroutineScope.launch { - composePagerState.animateScrollToPage(index) - } - }, - text = { - BadgedBox( - badge = { - if (tab.mentionCount > 0) { - Badge { Text("${tab.mentionCount}") } - } - } - ) { - Text(text = tab.displayName) - } - } - ) + ChannelTabRow( + tabs = tabState.tabs, + selectedIndex = tabState.selectedIndex, + onTabSelected = { + channelTabViewModel.selectTab(it) + scope.launch { + composePagerState.animateScrollToPage(it) + } } - } + ) // Chat pager - State from ChannelPagerViewModel HorizontalPager( @@ -191,7 +220,16 @@ fun MainScreen( val channel = pagerState.channels[page] ChatComposable( channel = channel, - onUserClick = onUserClick, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + // Always open popup for now (long press handled same as click) + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, onReplyClick = { replyMessageId -> diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt index b0bfe593f..17dbd320e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt @@ -1,6 +1,15 @@ package com.flxrs.dankchat.main.compose +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.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -12,9 +21,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Android import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard @@ -32,25 +39,46 @@ fun SuggestionDropdown( onSuggestionClick: (Suggestion) -> Unit, modifier: Modifier = Modifier ) { - if (suggestions.isEmpty()) return - - OutlinedCard( - modifier = modifier - .padding(horizontal = 2.dp) - .fillMaxWidth(0.66f) - .heightIn(max = 250.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) + 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) ) { - LazyColumn( + OutlinedCard( modifier = Modifier - .fillMaxWidth() - .animateContentSize(), + .padding(horizontal = 2.dp) + .fillMaxWidth(0.66f) + .heightIn(max = 250.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) ) { - items(suggestions, key = { it.toString() }) { suggestion -> - SuggestionItem( - suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) }, - ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + ) { + items(suggestions, key = { it.toString() }) { suggestion -> + SuggestionItem( + suggestion = suggestion, + onClick = { onSuggestionClick(suggestion) }, + ) + } } } } @@ -71,7 +99,7 @@ private fun SuggestionItem( ) { // Icon/Image based on suggestion type when (suggestion) { - is Suggestion.EmoteSuggestion -> { + is Suggestion.EmoteSuggestion -> { AsyncImage( model = suggestion.emote.url, contentDescription = suggestion.emote.code, @@ -85,7 +113,7 @@ private fun SuggestionItem( ) } - is Suggestion.UserSuggestion -> { + is Suggestion.UserSuggestion -> { Icon( imageVector = Icons.Default.Person, contentDescription = "User", diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt new file mode 100644 index 000000000..751c33e13 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt @@ -0,0 +1,71 @@ +package com.flxrs.dankchat.main.compose.dialogs + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName + +@Composable +fun AddChannelDialog( + onDismiss: () -> Unit, + onAddChannel: (UserName) -> Unit, +) { + var channelName by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_channel)) }, + text = { + OutlinedTextField( + value = channelName, + onValueChange = { channelName = it }, + label = { Text(stringResource(R.string.add_channel_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + if (channelName.isNotBlank()) { + onAddChannel(UserName(channelName)) + onDismiss() + } + }), + modifier = Modifier.focusRequester(focusRequester) + ) + }, + confirmButton = { + TextButton( + onClick = { + onAddChannel(UserName(channelName)) + onDismiss() + }, + enabled = channelName.isNotBlank() + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} 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 index d7da086ee..c98a400b7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt @@ -76,75 +76,9 @@ class AboutFragment : Fragment() { 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()), - ) - } - ) - } - } - } + AboutScreen( + onBackPressed = { navController.popBackStack() }, + ) } } } 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..4b70b6042 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -0,0 +1,120 @@ +package com.flxrs.dankchat.preferences.about + +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.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 com.flxrs.dankchat.R +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.Dispatchers +import kotlinx.coroutines.withContext +import sh.calvin.autolinktext.TextRuleDefaults +import sh.calvin.autolinktext.annotateString + +@Composable +fun AboutScreen( + onBackPressed: () -> Unit, +) { + 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 = onBackPressed, + content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.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/appearance/AppearanceSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt index 12f18fc5b..c6f8254ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt @@ -93,14 +93,8 @@ class AppearanceSettingsFragment : Fragment() { 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) }, + AppearanceSettingsScreen( onBackPressed = { findNavController().popBackStack() } ) } @@ -108,239 +102,3 @@ class AppearanceSettingsFragment : Fragment() { } } } - -@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() - // minSdk 30 always supports light mode and system dark mode - val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) - val lightThemeTitle = 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 { - 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..60a12f0ab --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -0,0 +1,313 @@ +package com.flxrs.dankchat.preferences.appearance + +import android.app.Activity +import android.content.Context +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.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.core.app.ActivityCompat +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.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 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( + onBackPressed: () -> Unit, +) { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value + + AppearanceSettingsContent( + settings = settings, + onInteraction = { viewModel.onInteraction(it) }, + onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, + onBackPressed = onBackPressed + ) +} + +@Composable +private fun AppearanceSettingsContent( + 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() + // minSdk 30 always supports light mode and system dark mode + val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) + val lightThemeTitle = 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 { + 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/developer/DeveloperSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt index 195fe74b5..7a1f4794b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt @@ -109,36 +109,8 @@ class DeveloperSettingsFragment : Fragment() { 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) }, + DeveloperSettingsScreen( onBackPressed = { findNavController().popBackStack() }, ) } @@ -146,326 +118,3 @@ class DeveloperSettingsFragment : Fragment() { } } } - -@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)) }, - ) - SwitchPreferenceItem( - title = "Use Compose Chat UI", - summary = "Enable new Compose-based chat interface (experimental)", - isChecked = settings.useComposeChatUi, - onClick = { onInteraction(DeveloperSettingsInteraction.UseComposeChatUi(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..db53b5f2b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -0,0 +1,448 @@ +package com.flxrs.dankchat.preferences.developer + +import android.content.ClipData +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.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.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.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.lifecycle.compose.collectAsStateWithLifecycle +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.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( + onBackPressed: () -> 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.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + ProcessPhoenix.triggerRebirth(context) + } + } + } + } + } + + DeveloperSettingsContent( + settings = settings, + snackbarHostState = snackbarHostState, + onInteraction = { viewModel.onInteraction(it) }, + onBackPressed = onBackPressed, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeveloperSettingsContent( + settings: DeveloperSettings, + snackbarHostState: 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)) }, + ) + SwitchPreferenceItem( + title = "Use Compose Chat UI", + summary = "Enable new Compose-based chat interface (experimental)", + isChecked = settings.useComposeChatUi, + onClick = { onInteraction(DeveloperSettingsInteraction.UseComposeChatUi(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() + } + } +} + +@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)) }) { + 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( + 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) } + 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(), + 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() + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@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/overview/OverviewSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt index 18a68a920..fbb555eb4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt @@ -84,7 +84,7 @@ class OverviewSettingsFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { DankChatTheme { - OverviewSettings( + OverviewSettingsScreen( isLoggedIn = dankChatPreferences.isLoggedIn, hasChangelog = DankChatVersion.HAS_CHANGELOG, onBackPressed = { navController.popBackStack() }, @@ -108,123 +108,3 @@ class OverviewSettingsFragment : Fragment() { } } -@Composable -private fun OverviewSettings( - isLoggedIn: Boolean, - hasChangelog: Boolean, - onBackPressed: () -> Unit, - onLogoutRequested: () -> Unit, - onNavigateRequested: (id: Int) -> Unit, -) { - 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.settings)) }, - navigationIcon = { - IconButton( - onClick = onBackPressed, - 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()), - ) { - PreferenceItem( - title = stringResource(R.string.preference_appearance_header), - icon = Icons.Default.Palette, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment) }, - ) - PreferenceItem( - title = stringResource(R.string.preference_highlights_ignores_header), - icon = Icons.Default.NotificationsActive, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment) }, - ) - PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_chatSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_streamsSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_toolsSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_developerSettingsFragment) - }) - - AnimatedVisibility(hasChangelog) { - PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_changelogSheetFragment) - }) - } - - PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogoutRequested) - SecretDankerModeTrigger { - PreferenceCategoryWithSummary( - title = { - PreferenceCategoryTitle( - text = stringResource(R.string.preference_about_header), - modifier = Modifier.dankClickable(), - ) - }, - ) { - 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) - } - } - PreferenceSummary(annotated, Modifier.padding(top = 16.dp)) - } - } - NavigationBarSpacer() - } - } -} - -@Composable -@PreviewDynamicColors -@PreviewLightDark -private fun OverviewSettingsPreview() { - DankChatTheme { - OverviewSettings( - isLoggedIn = false, - hasChangelog = true, - onBackPressed = { }, - onLogoutRequested = { }, - onNavigateRequested = { }, - ) - } -} - -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/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt new file mode 100644 index 000000000..a118051be --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -0,0 +1,169 @@ +package com.flxrs.dankchat.preferences.overview + +import androidx.compose.animation.AnimatedVisibility +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.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.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.Construction +import androidx.compose.material.icons.filled.DeveloperMode +import androidx.compose.material.icons.filled.FiberNew +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PlayArrow +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.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.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 com.flxrs.dankchat.BuildConfig +import com.flxrs.dankchat.R +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.utils.compose.buildClickableAnnotation +import com.flxrs.dankchat.utils.compose.buildLinkAnnotation + +private const val GITHUB_URL = "https://github.com/flex3r/dankchat" +private const val TWITCH_TOS_URL = "https://www.twitch.tv/p/terms-of-service" + +@Composable +fun OverviewSettingsScreen( + isLoggedIn: Boolean, + hasChangelog: Boolean, + onBackPressed: () -> Unit, + onLogoutRequested: () -> Unit, + onNavigateRequested: (id: Int) -> Unit, +) { + 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.settings)) }, + navigationIcon = { + IconButton( + onClick = onBackPressed, + 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()), + ) { + PreferenceItem( + title = stringResource(R.string.preference_appearance_header), + icon = Icons.Default.Palette, + onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment) }, + ) + PreferenceItem( + title = stringResource(R.string.preference_highlights_ignores_header), + icon = Icons.Default.NotificationsActive, + onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment) }, + ) + PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_chatSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_streamsSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_toolsSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_developerSettingsFragment) + }) + + AnimatedVisibility(hasChangelog) { + PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_changelogSheetFragment) + }) + } + + PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogoutRequested) + SecretDankerModeTrigger { + PreferenceCategoryWithSummary( + title = { + PreferenceCategoryTitle( + text = stringResource(R.string.preference_about_header), + modifier = Modifier.dankClickable(), + ) + }, + ) { + 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) + } + } + PreferenceSummary(annotated, Modifier.padding(top = 16.dp)) + } + } + NavigationBarSpacer() + } + } +} + +@Composable +@PreviewDynamicColors +@PreviewLightDark +private fun OverviewSettingsPreview() { + DankChatTheme { + OverviewSettingsScreen( + isLoggedIn = false, + hasChangelog = true, + onBackPressed = { }, + onLogoutRequested = { }, + onNavigateRequested = { }, + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt index b0c4eb8a7..0732e6579 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt @@ -61,13 +61,8 @@ class StreamsSettingsFragment : Fragment() { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value - DankChatTheme { - StreamsSettings( - settings = settings, - onInteraction = { viewModel.onInteraction(it) }, + StreamsSettingsScreen( onBackPressed = { findNavController().popBackStack() }, ) } @@ -75,79 +70,3 @@ class StreamsSettingsFragment : Fragment() { } } } - -@Composable -private fun StreamsSettings( - settings: StreamsSettings, - onInteraction: (StreamsSettingsInteraction) -> 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_streams_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_fetch_streams_title), - summary = stringResource(R.string.preference_fetch_streams_summary), - isChecked = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.FetchStreams(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_streaminfo_title), - summary = stringResource(R.string.preference_streaminfo_summary), - isChecked = settings.showStreamInfo, - isEnabled = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamInfo(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_streaminfo_category_title), - summary = stringResource(R.string.preference_streaminfo_category_summary), - isChecked = settings.showStreamCategory, - isEnabled = settings.fetchStreams && settings.showStreamInfo, - onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamCategory(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_retain_webview_title), - summary = stringResource(R.string.preference_retain_webview_summary), - isChecked = settings.preventStreamReloads, - isEnabled = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.PreventStreamReloads(it)) }, - ) - - val activity = LocalActivity.current - 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), - summary = stringResource(R.string.preference_pip_summary), - isChecked = settings.enablePiP, - isEnabled = settings.fetchStreams && settings.preventStreamReloads, - onClick = { onInteraction(StreamsSettingsInteraction.EnablePiP(it)) }, - ) - } - NavigationBarSpacer() - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt new file mode 100644 index 000000000..55c483f7b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt @@ -0,0 +1,122 @@ +package com.flxrs.dankchat.preferences.stream + +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.LocalActivity +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.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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.components.NavigationBarSpacer +import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun StreamsSettingsScreen( + onBackPressed: () -> Unit, +) { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value + + StreamsSettingsContent( + settings = settings, + onInteraction = { viewModel.onInteraction(it) }, + onBackPressed = onBackPressed + ) +} + +@Composable +private fun StreamsSettingsContent( + settings: StreamsSettings, + onInteraction: (StreamsSettingsInteraction) -> 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_streams_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_fetch_streams_title), + summary = stringResource(R.string.preference_fetch_streams_summary), + isChecked = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.FetchStreams(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_streaminfo_title), + summary = stringResource(R.string.preference_streaminfo_summary), + isChecked = settings.showStreamInfo, + isEnabled = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamInfo(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_streaminfo_category_title), + summary = stringResource(R.string.preference_streaminfo_category_summary), + isChecked = settings.showStreamCategory, + isEnabled = settings.fetchStreams && settings.showStreamInfo, + onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamCategory(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_retain_webview_title), + summary = stringResource(R.string.preference_retain_webview_summary), + isChecked = settings.preventStreamReloads, + isEnabled = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.PreventStreamReloads(it)) }, + ) + + val activity = LocalActivity.current + 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), + summary = stringResource(R.string.preference_pip_summary), + isChecked = settings.enablePiP, + isEnabled = settings.fetchStreams && settings.preventStreamReloads, + onClick = { onInteraction(StreamsSettingsInteraction.EnablePiP(it)) }, + ) + } + NavigationBarSpacer() + } + } +} 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 index 6cf52e71d..0973ab3ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -2,6 +2,7 @@ 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 @@ -101,6 +102,7 @@ fun Modifier.avoidRoundedCorners(fallback: PaddingValues): Modifier = composed { ) } +@RequiresApi(api = 31) private fun RoundedCorner.calculateTopPaddingForComponent( componentX: Int, componentTop: Int @@ -117,6 +119,7 @@ private fun RoundedCorner.calculateTopPaddingForComponent( return max(0, topBoundary - componentTop) } +@RequiresApi(api = 31) private fun RoundedCorner.calculateBottomPaddingForComponent( componentX: Int, componentBottom: Int @@ -133,6 +136,7 @@ private fun RoundedCorner.calculateBottomPaddingForComponent( return max(0, componentBottom - bottomBoundary) } +@RequiresApi(api = 31) private fun RoundedCorner.calculateStartPaddingForComponent( componentLeft: Int, componentY: Int @@ -149,6 +153,7 @@ private fun RoundedCorner.calculateStartPaddingForComponent( return max(0, leftBoundary - componentLeft) } +@RequiresApi(api = 31) private fun RoundedCorner.calculateEndPaddingForComponent( componentRight: Int, componentY: Int From 988fa8634cf1183b3b799f30e4f285aaf68c41ed Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 007/349] feat(compose): Add user popup, channel management, and navigation --- .../com/flxrs/dankchat/DankChatViewModel.kt | 23 - .../dankchat/changelog/ChangelogScreen.kt | 78 --- .../com/flxrs/dankchat/main/MainActivity.kt | 233 +++------ .../preferences/about/AboutFragment.kt | 72 ++- .../dankchat/preferences/about/AboutScreen.kt | 120 ----- .../appearance/AppearanceSettingsFragment.kt | 244 +++++++++- .../appearance/AppearanceSettingsScreen.kt | 313 ------------ .../developer/DeveloperSettingsFragment.kt | 353 +++++++++++++- .../developer/DeveloperSettingsScreen.kt | 448 ------------------ .../overview/OverviewSettingsFragment.kt | 122 ++++- .../overview/OverviewSettingsScreen.kt | 169 ------- .../stream/StreamsSettingsFragment.kt | 83 +++- .../stream/StreamsSettingsScreen.kt | 122 ----- 13 files changed, 923 insertions(+), 1457 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 63df75c00..13f2c5d74 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -21,12 +21,6 @@ import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds -import android.webkit.CookieManager -import android.webkit.WebStorage -import com.flxrs.dankchat.data.repo.IgnoresRepository -import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository - @KoinViewModel class DankChatViewModel( private val chatRepository: ChatRepository, @@ -34,9 +28,6 @@ class DankChatViewModel( private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val authApiClient: AuthApiClient, private val dataRepository: DataRepository, - private val ignoresRepository: IgnoresRepository, - private val userStateRepository: UserStateRepository, - private val emoteUsageRepository: EmoteUsageRepository, ) : ViewModel() { val serviceEvents = dataRepository.serviceEvents @@ -77,20 +68,6 @@ class DankChatViewModel( } } - fun clearDataForLogout() { - CookieManager.getInstance().removeAllCookies(null) - WebStorage.getInstance().deleteAllData() - - dankChatPreferenceStore.clearLogin() - userStateRepository.clear() - - chatRepository.closeAndReconnect() - ignoresRepository.clearIgnores() - viewModelScope.launch { - emoteUsageRepository.clearUsages() - } - } - private suspend fun validateUser() { // no token = nothing to validate 4head val token = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt deleted file mode 100644 index 417a6d55d..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.flxrs.dankchat.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( - onBackPressed: () -> 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 = onBackPressed, - 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() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index d52af07a0..fee378438 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -25,9 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController import androidx.navigation.findNavController import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R @@ -37,21 +34,7 @@ import com.flxrs.dankchat.data.repo.data.ServiceEvent import com.flxrs.dankchat.databinding.MainActivityBinding import com.flxrs.dankchat.main.compose.MainScreen import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.preferences.about.AboutScreen -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsScreen -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.DeveloperSettingsDataStore -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.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.theme.DankChatTheme import com.flxrs.dankchat.utils.extensions.hasPermission import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu @@ -152,171 +135,67 @@ class MainActivity : AppCompatActivity() { private fun setupComposeUi() { setContent { DankChatTheme { - val navController = rememberNavController() val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle( initialValue = developerSettingsDataStore.current() ) - NavHost(navController = navController, startDestination = "main") { - composable("main") { - MainScreen( - isLoggedIn = isLoggedIn, - onNavigateToSettings = { - navController.navigate("settings") - }, - onMessageLongClick = { messageId, channel, fullMessage -> - // TODO: Show message options dialog - }, - onEmoteClick = { emotes -> - // TODO: Show emote overlay/fullscreen - }, - onLogin = { - // TODO: Navigate to login - }, - onRelogin = { - // TODO: Navigate to login - }, - onLogout = { - // TODO: Show logout confirmation - }, - onManageChannels = { - // TODO: Show manage channels dialog - }, - onOpenChannel = { - // TODO: Open channel in browser - }, - onRemoveChannel = { - // TODO: Remove active channel - }, - onReportChannel = { - // TODO: Report channel - }, - onBlockChannel = { - // TODO: Block channel - }, - onReloadEmotes = { - // TODO: Reload emotes - }, - onReconnect = { - // TODO: Reconnect to chat - }, - onClearChat = { - // TODO: Clear chat messages - }, - onCaptureImage = { - // TODO: Capture image - }, - onCaptureVideo = { - // TODO: Capture video - }, - onChooseMedia = { - // TODO: Choose media - }, - onAddChannel = { - // TODO: Show add channel dialog - } - ) + MainScreen( + isLoggedIn = isLoggedIn, + onNavigateToSettings = { + // TODO: Navigate to settings (need to implement Compose settings or use dialog) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + // TODO: Show message options dialog + }, + onEmoteClick = { emotes -> + // TODO: Show emote overlay/fullscreen + }, + onLogin = { + // TODO: Navigate to login + }, + onRelogin = { + // TODO: Navigate to login + }, + onLogout = { + // TODO: Show logout confirmation + }, + onManageChannels = { + // TODO: Show manage channels dialog + }, + onOpenChannel = { + // TODO: Open channel in browser + }, + onRemoveChannel = { + // TODO: Remove active channel + }, + onReportChannel = { + // TODO: Report channel + }, + onBlockChannel = { + // TODO: Block channel + }, + onReloadEmotes = { + // TODO: Reload emotes + }, + onReconnect = { + // TODO: Reconnect to chat + }, + onClearChat = { + // TODO: Clear chat messages + }, + onCaptureImage = { + // TODO: Capture image + }, + onCaptureVideo = { + // TODO: Capture video + }, + onChooseMedia = { + // TODO: Choose media + }, + onAddChannel = { + // TODO: Show add channel dialog } - composable("settings") { - OverviewSettingsScreen( - isLoggedIn = isLoggedIn, - hasChangelog = com.flxrs.dankchat.changelog.DankChatVersion.HAS_CHANGELOG, - onBackPressed = { navController.popBackStack() }, - onLogoutRequested = { - viewModel.clearDataForLogout() - navController.popBackStack() - }, - onNavigateRequested = { destinationId -> - when (destinationId) { - R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment -> navController.navigate("appearance") - R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment -> navController.navigate("notifications") - R.id.action_overviewSettingsFragment_to_chatSettingsFragment -> navController.navigate("chat") - R.id.action_overviewSettingsFragment_to_streamsSettingsFragment -> navController.navigate("streams") - R.id.action_overviewSettingsFragment_to_toolsSettingsFragment -> navController.navigate("tools") - R.id.action_overviewSettingsFragment_to_developerSettingsFragment -> navController.navigate("developer") - R.id.action_overviewSettingsFragment_to_changelogSheetFragment -> navController.navigate("changelog") - R.id.action_overviewSettingsFragment_to_aboutFragment -> navController.navigate("about") - } - } - ) - } - composable("appearance") { - AppearanceSettingsScreen( - onBackPressed = { navController.popBackStack() } - ) - } - composable("notifications") { - NotificationsSettingsScreen( - onNavToHighlights = { navController.navigate("highlights") }, - onNavToIgnores = { navController.navigate("ignores") }, - onNavBack = { navController.popBackStack() } - ) - } - composable("highlights") { - HighlightsScreen( - onNavBack = { navController.popBackStack() } - ) - } - composable("ignores") { - IgnoresScreen( - onNavBack = { navController.popBackStack() } - ) - } - composable("chat") { - ChatSettingsScreen( - onNavToCommands = { navController.navigate("commands") }, - onNavToUserDisplays = { navController.navigate("user_display") }, - onNavBack = { navController.popBackStack() } - ) - } - composable("commands") { - CustomCommandsScreen( - onNavBack = { navController.popBackStack() } - ) - } - composable("user_display") { - UserDisplayScreen( - onNavBack = { navController.popBackStack() } - ) - } - composable("streams") { - StreamsSettingsScreen( - onBackPressed = { navController.popBackStack() } - ) - } - composable("tools") { - ToolsSettingsScreen( - onNavToImageUploader = { navController.navigate("image_uploader") }, - onNavToTTSUserIgnoreList = { navController.navigate("tts_ignore_list") }, - onNavBack = { navController.popBackStack() } - ) - } - composable("image_uploader") { - ImageUploaderScreen( - onNavBack = { navController.popBackStack() } - ) - } - composable("tts_ignore_list") { - TTSUserIgnoreListScreen( - onNavBack = { navController.popBackStack() } - ) - } - composable("developer") { - DeveloperSettingsScreen( - onBackPressed = { navController.popBackStack() } - ) - } - composable("changelog") { - com.flxrs.dankchat.changelog.ChangelogScreen( - onBackPressed = { navController.popBackStack() } - ) - } - composable("about") { - AboutScreen( - onBackPressed = { navController.popBackStack() } - ) - } - } + ) } } } 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 index c98a400b7..d7da086ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt @@ -76,9 +76,75 @@ class AboutFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { DankChatTheme { - AboutScreen( - onBackPressed = { navController.popBackStack() }, - ) + 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 deleted file mode 100644 index 4b70b6042..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.flxrs.dankchat.preferences.about - -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.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 com.flxrs.dankchat.R -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.Dispatchers -import kotlinx.coroutines.withContext -import sh.calvin.autolinktext.TextRuleDefaults -import sh.calvin.autolinktext.annotateString - -@Composable -fun AboutScreen( - onBackPressed: () -> Unit, -) { - 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 = onBackPressed, - content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.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/appearance/AppearanceSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt index c6f8254ed..12f18fc5b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt @@ -93,8 +93,14 @@ class AppearanceSettingsFragment : Fragment() { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value + DankChatTheme { - AppearanceSettingsScreen( + AppearanceSettings( + settings = settings, + onInteraction = { viewModel.onInteraction(it) }, + onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, onBackPressed = { findNavController().popBackStack() } ) } @@ -102,3 +108,239 @@ class AppearanceSettingsFragment : Fragment() { } } } + +@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() + // minSdk 30 always supports light mode and system dark mode + val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) + val lightThemeTitle = 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 { + 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 deleted file mode 100644 index 60a12f0ab..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ /dev/null @@ -1,313 +0,0 @@ -package com.flxrs.dankchat.preferences.appearance - -import android.app.Activity -import android.content.Context -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.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.core.app.ActivityCompat -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.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 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( - onBackPressed: () -> Unit, -) { - val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value - - AppearanceSettingsContent( - settings = settings, - onInteraction = { viewModel.onInteraction(it) }, - onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, - onBackPressed = onBackPressed - ) -} - -@Composable -private fun AppearanceSettingsContent( - 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() - // minSdk 30 always supports light mode and system dark mode - val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) - val lightThemeTitle = 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 { - 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/developer/DeveloperSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt index 7a1f4794b..195fe74b5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt @@ -109,8 +109,36 @@ class DeveloperSettingsFragment : Fragment() { 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 { - DeveloperSettingsScreen( + DeveloperSettings( + settings = settings, + snackbarHostState = snackbarHostState, + onInteraction = { viewModel.onInteraction(it) }, onBackPressed = { findNavController().popBackStack() }, ) } @@ -118,3 +146,326 @@ class DeveloperSettingsFragment : Fragment() { } } } + +@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)) }, + ) + SwitchPreferenceItem( + title = "Use Compose Chat UI", + summary = "Enable new Compose-based chat interface (experimental)", + isChecked = settings.useComposeChatUi, + onClick = { onInteraction(DeveloperSettingsInteraction.UseComposeChatUi(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 deleted file mode 100644 index db53b5f2b..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ /dev/null @@ -1,448 +0,0 @@ -package com.flxrs.dankchat.preferences.developer - -import android.content.ClipData -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.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.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.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.lifecycle.compose.collectAsStateWithLifecycle -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.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( - onBackPressed: () -> 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.Long, - ) - if (result == SnackbarResult.ActionPerformed) { - ProcessPhoenix.triggerRebirth(context) - } - } - } - } - } - - DeveloperSettingsContent( - settings = settings, - snackbarHostState = snackbarHostState, - onInteraction = { viewModel.onInteraction(it) }, - onBackPressed = onBackPressed, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DeveloperSettingsContent( - settings: DeveloperSettings, - snackbarHostState: 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)) }, - ) - SwitchPreferenceItem( - title = "Use Compose Chat UI", - summary = "Enable new Compose-based chat interface (experimental)", - isChecked = settings.useComposeChatUi, - onClick = { onInteraction(DeveloperSettingsInteraction.UseComposeChatUi(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() - } - } -} - -@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)) }) { - 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( - 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) } - 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(), - 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() - }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@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/overview/OverviewSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt index fbb555eb4..18a68a920 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt @@ -84,7 +84,7 @@ class OverviewSettingsFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { DankChatTheme { - OverviewSettingsScreen( + OverviewSettings( isLoggedIn = dankChatPreferences.isLoggedIn, hasChangelog = DankChatVersion.HAS_CHANGELOG, onBackPressed = { navController.popBackStack() }, @@ -108,3 +108,123 @@ class OverviewSettingsFragment : Fragment() { } } +@Composable +private fun OverviewSettings( + isLoggedIn: Boolean, + hasChangelog: Boolean, + onBackPressed: () -> Unit, + onLogoutRequested: () -> Unit, + onNavigateRequested: (id: Int) -> Unit, +) { + 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.settings)) }, + navigationIcon = { + IconButton( + onClick = onBackPressed, + 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()), + ) { + PreferenceItem( + title = stringResource(R.string.preference_appearance_header), + icon = Icons.Default.Palette, + onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment) }, + ) + PreferenceItem( + title = stringResource(R.string.preference_highlights_ignores_header), + icon = Icons.Default.NotificationsActive, + onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment) }, + ) + PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_chatSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_streamsSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_toolsSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_developerSettingsFragment) + }) + + AnimatedVisibility(hasChangelog) { + PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_changelogSheetFragment) + }) + } + + PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogoutRequested) + SecretDankerModeTrigger { + PreferenceCategoryWithSummary( + title = { + PreferenceCategoryTitle( + text = stringResource(R.string.preference_about_header), + modifier = Modifier.dankClickable(), + ) + }, + ) { + 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) + } + } + PreferenceSummary(annotated, Modifier.padding(top = 16.dp)) + } + } + NavigationBarSpacer() + } + } +} + +@Composable +@PreviewDynamicColors +@PreviewLightDark +private fun OverviewSettingsPreview() { + DankChatTheme { + OverviewSettings( + isLoggedIn = false, + hasChangelog = true, + onBackPressed = { }, + onLogoutRequested = { }, + onNavigateRequested = { }, + ) + } +} + +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/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt deleted file mode 100644 index a118051be..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.flxrs.dankchat.preferences.overview - -import androidx.compose.animation.AnimatedVisibility -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.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.material.icons.automirrored.filled.ExitToApp -import androidx.compose.material.icons.filled.Construction -import androidx.compose.material.icons.filled.DeveloperMode -import androidx.compose.material.icons.filled.FiberNew -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.NotificationsActive -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.PlayArrow -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.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.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 com.flxrs.dankchat.BuildConfig -import com.flxrs.dankchat.R -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.utils.compose.buildClickableAnnotation -import com.flxrs.dankchat.utils.compose.buildLinkAnnotation - -private const val GITHUB_URL = "https://github.com/flex3r/dankchat" -private const val TWITCH_TOS_URL = "https://www.twitch.tv/p/terms-of-service" - -@Composable -fun OverviewSettingsScreen( - isLoggedIn: Boolean, - hasChangelog: Boolean, - onBackPressed: () -> Unit, - onLogoutRequested: () -> Unit, - onNavigateRequested: (id: Int) -> Unit, -) { - 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.settings)) }, - navigationIcon = { - IconButton( - onClick = onBackPressed, - 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()), - ) { - PreferenceItem( - title = stringResource(R.string.preference_appearance_header), - icon = Icons.Default.Palette, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment) }, - ) - PreferenceItem( - title = stringResource(R.string.preference_highlights_ignores_header), - icon = Icons.Default.NotificationsActive, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment) }, - ) - PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_chatSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_streamsSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_toolsSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_developerSettingsFragment) - }) - - AnimatedVisibility(hasChangelog) { - PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_changelogSheetFragment) - }) - } - - PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogoutRequested) - SecretDankerModeTrigger { - PreferenceCategoryWithSummary( - title = { - PreferenceCategoryTitle( - text = stringResource(R.string.preference_about_header), - modifier = Modifier.dankClickable(), - ) - }, - ) { - 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) - } - } - PreferenceSummary(annotated, Modifier.padding(top = 16.dp)) - } - } - NavigationBarSpacer() - } - } -} - -@Composable -@PreviewDynamicColors -@PreviewLightDark -private fun OverviewSettingsPreview() { - DankChatTheme { - OverviewSettingsScreen( - isLoggedIn = false, - hasChangelog = true, - onBackPressed = { }, - onLogoutRequested = { }, - onNavigateRequested = { }, - ) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt index 0732e6579..b0c4eb8a7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt @@ -61,8 +61,13 @@ class StreamsSettingsFragment : Fragment() { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value + DankChatTheme { - StreamsSettingsScreen( + StreamsSettings( + settings = settings, + onInteraction = { viewModel.onInteraction(it) }, onBackPressed = { findNavController().popBackStack() }, ) } @@ -70,3 +75,79 @@ class StreamsSettingsFragment : Fragment() { } } } + +@Composable +private fun StreamsSettings( + settings: StreamsSettings, + onInteraction: (StreamsSettingsInteraction) -> 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_streams_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_fetch_streams_title), + summary = stringResource(R.string.preference_fetch_streams_summary), + isChecked = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.FetchStreams(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_streaminfo_title), + summary = stringResource(R.string.preference_streaminfo_summary), + isChecked = settings.showStreamInfo, + isEnabled = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamInfo(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_streaminfo_category_title), + summary = stringResource(R.string.preference_streaminfo_category_summary), + isChecked = settings.showStreamCategory, + isEnabled = settings.fetchStreams && settings.showStreamInfo, + onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamCategory(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_retain_webview_title), + summary = stringResource(R.string.preference_retain_webview_summary), + isChecked = settings.preventStreamReloads, + isEnabled = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.PreventStreamReloads(it)) }, + ) + + val activity = LocalActivity.current + 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), + summary = stringResource(R.string.preference_pip_summary), + isChecked = settings.enablePiP, + isEnabled = settings.fetchStreams && settings.preventStreamReloads, + onClick = { onInteraction(StreamsSettingsInteraction.EnablePiP(it)) }, + ) + } + NavigationBarSpacer() + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt deleted file mode 100644 index 55c483f7b..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.flxrs.dankchat.preferences.stream - -import android.content.pm.PackageManager -import android.os.Build -import androidx.activity.compose.LocalActivity -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.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.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flxrs.dankchat.R -import com.flxrs.dankchat.preferences.components.NavigationBarSpacer -import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun StreamsSettingsScreen( - onBackPressed: () -> Unit, -) { - val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value - - StreamsSettingsContent( - settings = settings, - onInteraction = { viewModel.onInteraction(it) }, - onBackPressed = onBackPressed - ) -} - -@Composable -private fun StreamsSettingsContent( - settings: StreamsSettings, - onInteraction: (StreamsSettingsInteraction) -> 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_streams_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_fetch_streams_title), - summary = stringResource(R.string.preference_fetch_streams_summary), - isChecked = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.FetchStreams(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_streaminfo_title), - summary = stringResource(R.string.preference_streaminfo_summary), - isChecked = settings.showStreamInfo, - isEnabled = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamInfo(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_streaminfo_category_title), - summary = stringResource(R.string.preference_streaminfo_category_summary), - isChecked = settings.showStreamCategory, - isEnabled = settings.fetchStreams && settings.showStreamInfo, - onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamCategory(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_retain_webview_title), - summary = stringResource(R.string.preference_retain_webview_summary), - isChecked = settings.preventStreamReloads, - isEnabled = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.PreventStreamReloads(it)) }, - ) - - val activity = LocalActivity.current - 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), - summary = stringResource(R.string.preference_pip_summary), - isChecked = settings.enablePiP, - isEnabled = settings.fetchStreams && settings.preventStreamReloads, - onClick = { onInteraction(StreamsSettingsInteraction.EnablePiP(it)) }, - ) - } - NavigationBarSpacer() - } - } -} From b8554133c2cb34ac3da4998273e53d6898d5d0ce Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:54 +0100 Subject: [PATCH 008/349] feat(compose): Add menu actions, dialogs, and input field parity --- .../com/flxrs/dankchat/DankChatApplication.kt | 2 +- .../com/flxrs/dankchat/DankChatViewModel.kt | 26 + .../dankchat/changelog/ChangelogScreen.kt | 78 +++ .../compose/TextWithMeasuredInlineContent.kt | 24 +- .../compose/EmoteInfoComposeViewModel.kt | 82 ++++ .../compose/MessageOptionsComposeViewModel.kt | 133 ++++++ .../message/compose/MessageOptionsParams.kt | 11 + .../compose/RepliesComposeViewModel.kt | 60 +++ .../chat/user/compose/UserPopupDialog.kt | 124 ++--- .../dankchat/login/compose/LoginScreen.kt | 133 ++++++ .../com/flxrs/dankchat/main/MainActivity.kt | 389 ++++++++++++--- .../flxrs/dankchat/main/MainDestination.kt | 54 +++ .../com/flxrs/dankchat/main/MainEvent.kt | 1 + .../com/flxrs/dankchat/main/MainFragment.kt | 1 + .../compose/ChannelManagementViewModel.kt | 33 ++ .../flxrs/dankchat/main/compose/ChannelTab.kt | 6 +- .../dankchat/main/compose/ChatInputLayout.kt | 20 +- .../main/compose/ChatInputViewModel.kt | 48 +- .../flxrs/dankchat/main/compose/MainAppBar.kt | 376 ++++++++------- .../dankchat/main/compose/MainEventBus.kt | 16 + .../flxrs/dankchat/main/compose/MainScreen.kt | 346 +++++++++++++- .../main/compose/dialogs/EditChannelDialog.kt | 97 ++++ .../main/compose/dialogs/EmoteInfoDialog.kt | 179 +++++++ .../compose/dialogs/ManageChannelsDialog.kt | 216 +++++++++ .../compose/dialogs/MessageOptionsDialog.kt | 250 ++++++++++ .../main/compose/sheets/MentionSheet.kt | 96 ++++ .../main/compose/sheets/RepliesSheet.kt | 83 ++++ .../preferences/DankChatPreferenceStore.kt | 12 + .../preferences/about/AboutFragment.kt | 72 +-- .../dankchat/preferences/about/AboutScreen.kt | 120 +++++ .../appearance/AppearanceSettingsFragment.kt | 244 +--------- .../appearance/AppearanceSettingsScreen.kt | 313 ++++++++++++ .../developer/DeveloperSettingsFragment.kt | 353 +------------- .../developer/DeveloperSettingsScreen.kt | 448 ++++++++++++++++++ .../overview/OverviewSettingsFragment.kt | 122 +---- .../overview/OverviewSettingsScreen.kt | 169 +++++++ .../stream/StreamsSettingsFragment.kt | 83 +--- .../stream/StreamsSettingsScreen.kt | 122 +++++ 38 files changed, 3747 insertions(+), 1195 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index c7cbe1d9b..2a8d83c5d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.launch 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.ksp.generated.* class DankChatApplication : Application(), SingletonImageLoader.Factory { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 13f2c5d74..aac01f354 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -21,6 +21,12 @@ import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds +import android.webkit.CookieManager +import android.webkit.WebStorage +import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository + @KoinViewModel class DankChatViewModel( private val chatRepository: ChatRepository, @@ -28,11 +34,17 @@ class DankChatViewModel( private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val authApiClient: AuthApiClient, private val dataRepository: DataRepository, + private val ignoresRepository: IgnoresRepository, + private val userStateRepository: UserStateRepository, + private val emoteUsageRepository: EmoteUsageRepository, ) : ViewModel() { val serviceEvents = dataRepository.serviceEvents private var started = false + val activeChannel = chatRepository.activeChannel + val isLoggedIn = dankChatPreferenceStore.isLoggedInFlow + private val _validationResult = Channel(Channel.BUFFERED) val validationResult get() = _validationResult.receiveAsFlow() @@ -68,6 +80,20 @@ class DankChatViewModel( } } + fun clearDataForLogout() { + CookieManager.getInstance().removeAllCookies(null) + WebStorage.getInstance().deleteAllData() + + dankChatPreferenceStore.clearLogin() + userStateRepository.clear() + + chatRepository.closeAndReconnect() + ignoresRepository.clearIgnores() + viewModelScope.launch { + emoteUsageRepository.clearUsages() + } + } + private suspend fun validateUser() { // no token = nothing to validate 4head val token = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt new file mode 100644 index 000000000..417a6d55d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt @@ -0,0 +1,78 @@ +package com.flxrs.dankchat.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( + onBackPressed: () -> 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 = onBackPressed, + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index 02a10d591..ae173714a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -121,14 +121,30 @@ fun TextWithMeasuredInlineContent( }, onTap = { offset -> textLayoutResult?.let { layoutResult -> - val position = layoutResult.getOffsetForPosition(offset) - onTextClick?.invoke(position) + // Precision check: make sure the click is actually on text + val isYWithinBounds = offset.y >= 0 && offset.y <= layoutResult.size.height + if (isYWithinBounds) { + val line = layoutResult.getLineForVerticalPosition(offset.y) + val isXWithinBounds = offset.x >= layoutResult.getLineLeft(line) && offset.x <= layoutResult.getLineRight(line) + if (isXWithinBounds) { + val position = layoutResult.getOffsetForPosition(offset) + onTextClick?.invoke(position) + } + } } }, onLongPress = { offset -> textLayoutResult?.let { layoutResult -> - val position = layoutResult.getOffsetForPosition(offset) - onTextLongClick?.invoke(position) + // Precision check: make sure the click is actually on text + val isYWithinBounds = offset.y >= 0 && offset.y <= layoutResult.size.height + if (isYWithinBounds) { + val line = layoutResult.getLineForVerticalPosition(offset.y) + val isXWithinBounds = offset.x >= layoutResult.getLineLeft(line) && offset.x <= layoutResult.getLineRight(line) + if (isXWithinBounds) { + val position = layoutResult.getOffsetForPosition(offset) + onTextLongClick?.invoke(position) + } + } } } ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt new file mode 100644 index 000000000..9a5e29ecb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt @@ -0,0 +1,82 @@ +package com.flxrs.dankchat.chat.emote.compose + +import androidx.lifecycle.ViewModel +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.emote.EmoteSheetItem +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 +import org.koin.core.annotation.InjectedParam + +@KoinViewModel +class EmoteInfoComposeViewModel( + @InjectedParam private val emotes: List, +) : ViewModel() { + + val items = 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/message/compose/MessageOptionsComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt new file mode 100644 index 000000000..95d4951c9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt @@ -0,0 +1,133 @@ +package com.flxrs.dankchat.chat.message.compose + +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.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.MutableStateFlow +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.android.annotation.KoinViewModel +import org.koin.core.annotation.InjectedParam +import kotlin.time.Duration.Companion.seconds + +@KoinViewModel +class MessageOptionsComposeViewModel( + @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, +) : ViewModel() { + + private val messageFlow = flowOf(chatRepository.findMessage(messageId, channel)) + private val connectionStateFlow = chatRepository.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 rootId = asPrivMessage?.thread?.rootId + val name = asPrivMessage?.name ?: asWhisperMessage?.name ?: return@combine MessageOptionsState.NotFound + val replyName = asPrivMessage?.thread?.name ?: name + val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage + MessageOptionsState.Found( + messageId = message.id, + rootThreadId = rootId ?: message.id, + replyName = replyName, + 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) + 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", + ) + } +} + +sealed interface MessageOptionsState { + data object Loading : MessageOptionsState + data object NotFound : MessageOptionsState + data class Found( + val messageId: String, + val rootThreadId: 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/chat/message/compose/MessageOptionsParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt new file mode 100644 index 000000000..e3c582ee9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt @@ -0,0 +1,11 @@ +package com.flxrs.dankchat.chat.message.compose + +import com.flxrs.dankchat.data.UserName + +data class MessageOptionsParams( + val messageId: String, + val channel: UserName?, + val fullMessage: String, + val canModerate: Boolean, + val canReply: Boolean, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt new file mode 100644 index 000000000..f065f166a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt @@ -0,0 +1,60 @@ +package com.flxrs.dankchat.chat.replies.compose + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.replies.RepliesState +import com.flxrs.dankchat.chat.replies.RepliesUiState +import com.flxrs.dankchat.data.repo.RepliesRepository +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.InjectedParam +import kotlin.time.Duration.Companion.seconds + +@KoinViewModel +class RepliesComposeViewModel( + @InjectedParam private val rootMessageId: String, + repliesRepository: RepliesRepository, + private val context: Context, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, +) : ViewModel() { + + val state = repliesRepository.getThreadItemsFlow(rootMessageId) + .map { + when { + it.isEmpty() -> RepliesState.NotFound + else -> RepliesState.Found(it) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(emptyList())) + + val uiState: StateFlow = combine( + state, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings + ) { repliesState, appearanceSettings, chatSettings -> + when (repliesState) { + is RepliesState.NotFound -> RepliesUiState.NotFound + is RepliesState.Found -> { + val uiMessages = repliesState.items.map { item -> + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + isAlternateBackground = false + ) + } + RepliesUiState.Found(uiMessages) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(emptyList())) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index 25aefc040..06cd17e2c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -14,14 +14,15 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items 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.Chat -import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Launch import androidx.compose.material.icons.filled.Message import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Report import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -29,8 +30,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.RichTooltip import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,20 +59,21 @@ import com.flxrs.dankchat.data.twitch.badge.Badge import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +import com.flxrs.dankchat.chat.compose.BadgeUi + @OptIn(ExperimentalMaterial3Api::class) @Composable fun UserPopupDialog( - params: UserPopupStateParams, + state: UserPopupState, + badges: List, + onBlockUser: () -> Unit, + onUnblockUser: () -> Unit, onDismiss: () -> Unit, onMention: (String, String) -> Unit, onWhisper: (String) -> Unit, onOpenChannel: (String) -> Unit, onReport: (String) -> Unit, ) { - val viewModel: UserPopupComposeViewModel = koinViewModel( - parameters = { parametersOf(params) } - ) - val state by viewModel.userPopupState.collectAsStateWithLifecycle() var showBlockConfirmation by remember { mutableStateOf(false) } ModalBottomSheet( @@ -138,68 +145,73 @@ fun UserPopupDialog( } } - if (params.badges.isNotEmpty()) { + if (badges.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - items(params.badges) { badge -> - AsyncImage( - model = badge.url, - contentDescription = badge.title, - modifier = Modifier.size(32.dp) - ) + items(badges) { badge -> + val title = badge.badge.title + if (title != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + 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) + ) + } } } } Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + Column( + modifier = Modifier.fillMaxWidth() ) { - ActionIconButton( - icon = Icons.Default.Chat, - label = stringResource(R.string.user_popup_mention), - onClick = { + UserPopupButton( + icon = Icons.Default.AlternateEmail, + text = stringResource(R.string.user_popup_mention), + onClick = { onMention(s.userName.value, s.displayName.value) onDismiss() } ) - ActionIconButton( - icon = Icons.Default.Message, - label = stringResource(R.string.user_popup_whisper), - onClick = { + UserPopupButton( + icon = Icons.AutoMirrored.Filled.Chat, + text = stringResource(R.string.user_popup_whisper), + onClick = { onWhisper(s.userName.value) onDismiss() } ) - ActionIconButton( + UserPopupButton( icon = Icons.Default.Block, - label = if (s.isBlocked) stringResource(R.string.user_popup_unblock) else stringResource(R.string.user_popup_block), + text = if (s.isBlocked) stringResource(R.string.user_popup_unblock) else stringResource(R.string.user_popup_block), onClick = { if (s.isBlocked) { - viewModel.unblockUser() + onUnblockUser() } else { showBlockConfirmation = true } } ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - ActionIconButton( - icon = Icons.Default.Launch, - label = stringResource(R.string.open_channel), - onClick = { onOpenChannel(s.userName.value) } - ) - ActionIconButton( - icon = Icons.Default.Flag, - label = stringResource(R.string.user_popup_report), + UserPopupButton( + icon = Icons.Default.Report, + text = stringResource(R.string.user_popup_report), onClick = { onReport(s.userName.value) } ) } @@ -216,7 +228,7 @@ fun UserPopupDialog( confirmButton = { TextButton( onClick = { - viewModel.blockUser() + onBlockUser() showBlockConfirmation = false } ) { @@ -233,16 +245,22 @@ fun UserPopupDialog( } @Composable -private fun ActionIconButton( +private fun UserPopupButton( icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, + text: String, onClick: () -> Unit ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.clickable(onClick = onClick).padding(8.dp) + TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), ) { - Icon(imageVector = icon, contentDescription = null) - Text(text = label, style = MaterialTheme.typography.labelSmall) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = icon, contentDescription = null) + Spacer(modifier = Modifier.width(32.dp)) + Text(text = text, style = MaterialTheme.typography.labelLarge) + } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt new file mode 100644 index 000000000..f058c0162 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt @@ -0,0 +1,133 @@ +package com.flxrs.dankchat.login.compose + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.os.Build +import android.util.Log +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.annotation.RequiresApi +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.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 androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.login.LoginViewModel +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + onCancel: () -> Unit, +) { + val viewModel: LoginViewModel = koinViewModel() + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + if (event.successful) { + onLoginSuccess() + } else { + // TODO: Show error? Legacy just navigates up mostly. + // onCancel() + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.login)) }, + navigationIcon = { + IconButton(onClick = onCancel) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.dialog_cancel)) + } + } + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + if (isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + AndroidView( + factory = { context -> + WebView(context).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?, url: String?): Boolean { + val urlString = url ?: "" + val fragment = urlString.toUri().fragment ?: return false + viewModel.parseToken(fragment) + return true // Consume + } + + @RequiresApi(Build.VERSION_CODES.N) + 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?) { + Log.e("LoginScreen", "Error: ${error?.description}") + isLoading = false + } + } + + loadUrl(viewModel.loginUrl) + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + + BackHandler { + onCancel() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index fee378438..eab437ea0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -14,6 +14,16 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +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.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat @@ -25,16 +35,36 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import androidx.navigation.findNavController +import androidx.core.net.toUri 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.login.compose.LoginScreen import com.flxrs.dankchat.main.compose.MainScreen +import com.flxrs.dankchat.main.compose.MainEventBus import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.about.AboutScreen +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsScreen +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.DeveloperSettingsDataStore +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.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.theme.DankChatTheme import com.flxrs.dankchat.utils.extensions.hasPermission import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu @@ -45,6 +75,7 @@ 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 kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -53,14 +84,12 @@ class MainActivity : AppCompatActivity() { private val viewModel: DankChatViewModel by viewModel() private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() private val dankChatPreferenceStore: DankChatPreferenceStore by inject() + private val mainEventBus: MainEventBus by inject() private val pendingChannelsToClear = mutableListOf() private var navController: NavController? = null private var bindingRef: MainActivityBinding? = null private val binding get() = bindingRef - private val isLoggedIn: Boolean - get() = dankChatPreferenceStore.isLoggedIn - private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { // just start the service, we don't care if the permission has been granted or not xd startService() @@ -135,67 +164,307 @@ class MainActivity : AppCompatActivity() { private fun setupComposeUi() { setContent { DankChatTheme { + val navController = rememberNavController() val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle( initialValue = developerSettingsDataStore.current() ) + val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle( + initialValue = dankChatPreferenceStore.isLoggedIn + ) - MainScreen( - isLoggedIn = isLoggedIn, - onNavigateToSettings = { - // TODO: Navigate to settings (need to implement Compose settings or use dialog) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - // TODO: Show message options dialog - }, - onEmoteClick = { emotes -> - // TODO: Show emote overlay/fullscreen - }, - onLogin = { - // TODO: Navigate to login - }, - onRelogin = { - // TODO: Navigate to login - }, - onLogout = { - // TODO: Show logout confirmation - }, - onManageChannels = { - // TODO: Show manage channels dialog - }, - onOpenChannel = { - // TODO: Open channel in browser - }, - onRemoveChannel = { - // TODO: Remove active channel - }, - onReportChannel = { - // TODO: Report channel - }, - onBlockChannel = { - // TODO: Block channel - }, - onReloadEmotes = { - // TODO: Reload emotes - }, - onReconnect = { - // TODO: Reconnect to chat - }, - onClearChat = { - // TODO: Clear chat messages - }, - onCaptureImage = { - // TODO: Capture image - }, - onCaptureVideo = { - // TODO: Capture video - }, - onChooseMedia = { - // TODO: Choose media - }, - onAddChannel = { - // TODO: Show add channel dialog + NavHost( + navController = navController, + startDestination = Main + ) { + composable
( + enterTransition = { slideInHorizontally(initialOffsetX = { -it }) }, + exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) }, + popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) }, + popExitTransition = { slideOutHorizontally(targetOffsetX = { -it }) } + ) { + MainScreen( + navController = navController, + isLoggedIn = isLoggedIn, + onNavigateToSettings = { + navController.navigate(Settings) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + // Handled in MainScreen with state + }, + onEmoteClick = { emotes -> + // Handled in MainScreen with state + }, + 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 -> + Intent(Intent.ACTION_VIEW).also { + it.data = url.toUri() + startActivity(it) + } + }, + onReloadEmotes = { + // Handled in MainScreen with ViewModel + }, + onReconnect = { + // Handled in MainScreen with ViewModel + }, + onCaptureImage = { + // TODO: Implement camera capture + }, + onCaptureVideo = { + // TODO: Implement camera capture + }, + onChooseMedia = { + // TODO: Implement media picker + } + ) } - ) + composable( + enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) }, + exitTransition = { fadeOut(animationSpec = tween(90)) }, + popEnterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) }, + popExitTransition = { fadeOut(animationSpec = tween(90)) } + ) { + LoginScreen( + onLoginSuccess = { navController.popBackStack() }, + onCancel = { navController.popBackStack() } + ) + } + composable( + enterTransition = { + if (initialState.destination.route?.contains("Main") == true) { + slideInHorizontally(initialOffsetX = { it }) + } else { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + } + }, + exitTransition = { + if (targetState.destination.route?.contains("Main") == true) { + slideOutHorizontally(targetOffsetX = { it }) + } else { + fadeOut(animationSpec = tween(90)) + } + }, + popEnterTransition = { + if (initialState.destination.route?.contains("Main") == true) { + slideInHorizontally(initialOffsetX = { it }) + } else { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + } + }, + popExitTransition = { + if (targetState.destination.route?.contains("Main") == true) { + slideOutHorizontally(targetOffsetX = { it }) + } else { + fadeOut(animationSpec = tween(90)) + } + } + ) { + OverviewSettingsScreen( + isLoggedIn = isLoggedIn, + hasChangelog = com.flxrs.dankchat.changelog.DankChatVersion.HAS_CHANGELOG, + onBackPressed = { navController.popBackStack() }, + onLogoutRequested = { + lifecycleScope.launch { + mainEventBus.emitEvent(MainEvent.LogOutRequested) + navController.popBackStack() + } + }, + onNavigateRequested = { destinationId -> + when (destinationId) { + R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment -> navController.navigate(AppearanceSettings) + R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment -> navController.navigate(NotificationsSettings) + R.id.action_overviewSettingsFragment_to_chatSettingsFragment -> navController.navigate(ChatSettings) + R.id.action_overviewSettingsFragment_to_streamsSettingsFragment -> navController.navigate(StreamsSettings) + R.id.action_overviewSettingsFragment_to_toolsSettingsFragment -> navController.navigate(ToolsSettings) + R.id.action_overviewSettingsFragment_to_developerSettingsFragment -> navController.navigate(DeveloperSettings) + R.id.action_overviewSettingsFragment_to_changelogSheetFragment -> navController.navigate(ChangelogSettings) + R.id.action_overviewSettingsFragment_to_aboutFragment -> navController.navigate(AboutSettings) + } + } + ) + } + + val settingsEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + } + val settingsExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { + fadeOut(animationSpec = tween(90)) + } + + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + AppearanceSettingsScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + NotificationsSettingsScreen( + onNavToHighlights = { navController.navigate(HighlightsSettings) }, + onNavToIgnores = { navController.navigate(IgnoresSettings) }, + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + HighlightsScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + IgnoresScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + ChatSettingsScreen( + onNavToCommands = { navController.navigate(CustomCommandsSettings) }, + onNavToUserDisplays = { navController.navigate(UserDisplaySettings) }, + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + CustomCommandsScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + UserDisplayScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + StreamsSettingsScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + ToolsSettingsScreen( + onNavToImageUploader = { navController.navigate(ImageUploaderSettings) }, + onNavToTTSUserIgnoreList = { navController.navigate(TTSUserIgnoreListSettings) }, + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + ImageUploaderScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + TTSUserIgnoreListScreen( + onNavBack = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + DeveloperSettingsScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + com.flxrs.dankchat.changelog.ChangelogScreen( + onBackPressed = { navController.popBackStack() } + ) + } + composable( + enterTransition = settingsEnterTransition, + exitTransition = settingsExitTransition, + popEnterTransition = settingsEnterTransition, + popExitTransition = settingsExitTransition + ) { + AboutScreen( + onBackPressed = { navController.popBackStack() } + ) + } + } } } } @@ -323,4 +592,4 @@ class MainActivity : AppCompatActivity() { private val TAG = MainActivity::class.java.simpleName const val OPEN_CHANNEL_KEY = "open_channel" } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt new file mode 100644 index 000000000..f94867fe3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt @@ -0,0 +1,54 @@ +package com.flxrs.dankchat.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 Login diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt index 6b1af5557..3477786f1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt @@ -2,4 +2,5 @@ package com.flxrs.dankchat.main sealed interface MainEvent { data class Error(val throwable: Throwable) : MainEvent + data object LogOutRequested : 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 index c145a6dab..a3308d2c0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt @@ -460,6 +460,7 @@ class MainFragment : Fragment() { collectFlow(events) { when (it) { is MainEvent.Error -> handleErrorEvent(it) + MainEvent.LogOutRequested -> showLogoutConfirmationDialog() } } collectFlow(channelMentionCount, ::updateChannelMentionBadges) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index 787d02af4..3a62e5cad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -13,11 +13,16 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel +import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.channel.ChannelRepository + @KoinViewModel class ChannelManagementViewModel( private val preferenceStore: DankChatPreferenceStore, private val channelDataCoordinator: ChannelDataCoordinator, private val chatRepository: ChatRepository, + private val ignoresRepository: IgnoresRepository, + private val channelRepository: ChannelRepository, ) : ViewModel() { val channels: StateFlow> = @@ -70,4 +75,32 @@ class ChannelManagementViewModel( fun reloadAllChannels() { channelDataCoordinator.reloadAllChannels() } + + fun reloadEmotes(channel: UserName) { + channelDataCoordinator.loadChannelData(channel) + } + + fun reconnect() { + chatRepository.reconnect() + } + + fun clearChat(channel: UserName) { + chatRepository.clear(channel) + } + + 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 reorderChannels(channels: List) { + preferenceStore.channels = channels.map { it.channel } + } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt index 046e7c142..d0f6f5b68 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt @@ -15,13 +15,15 @@ fun ChannelTab( val tabColor = when { tab.isSelected -> MaterialTheme.colorScheme.primary tab.mentionCount > 0 -> MaterialTheme.colorScheme.error - tab.hasUnread -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant // TODO maybe layer with alpha? + tab.hasUnread -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.onSurfaceVariant } Tab( selected = tab.isSelected, onClick = onClick, + selectedContentColor = tabColor, + unselectedContentColor = tabColor, text = { BadgedBox( badge = { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 2ecbe1591..a20010a42 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -15,22 +15,36 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.flxrs.dankchat.R +import com.flxrs.dankchat.main.InputState import com.flxrs.dankchat.utils.compose.avoidRoundedCorners @Composable fun ChatInputLayout( textFieldState: TextFieldState, + inputState: InputState, + enabled: Boolean, canSend: Boolean, onSend: () -> Unit, onEmoteClick: () -> Unit, modifier: Modifier = Modifier ) { + val hint = when (inputState) { + InputState.Default -> stringResource(R.string.hint_connected) + InputState.Replying -> stringResource(R.string.hint_replying) + InputState.NotLoggedIn -> stringResource(R.string.hint_not_logged_int) + InputState.Disconnected -> stringResource(R.string.hint_disconnected) + } + // Input field with TextFieldState OutlinedTextField( + enabled = enabled, shape = MaterialTheme.shapes.extraLarge, state = textFieldState, leadingIcon = { - IconButton(onClick = onEmoteClick) { + IconButton( + onClick = onEmoteClick, + enabled = enabled + ) { Icon( imageVector = Icons.Default.EmojiEmotions, contentDescription = stringResource(R.string.emote_menu_hint) @@ -52,11 +66,11 @@ fun ChatInputLayout( .fillMaxWidth() .avoidRoundedCorners(fallback = PaddingValues()), label = { - Text(stringResource(R.string.send_hint)) + Text(hint) }, lineLimits = androidx.compose.foundation.text.input.TextFieldLineLimits.MultiLine( minHeightInLines = 1, maxHeightInLines = 5 ) ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index d7d97f92f..e3f94c95c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -10,12 +10,17 @@ import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.chat.suggestion.SuggestionProvider import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.twitch.chat.ConnectionState +import com.flxrs.dankchat.main.InputState +import com.flxrs.dankchat.preferences.DankChatPreferenceStore import kotlinx.coroutines.FlowPreview +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.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @@ -25,10 +30,14 @@ import org.koin.android.annotation.KoinViewModel class ChatInputViewModel( private val chatRepository: ChatRepository, private val suggestionProvider: SuggestionProvider, + private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { val textFieldState = TextFieldState() + private val _isReplying = MutableStateFlow(false) + val isReplying: StateFlow = _isReplying + // Create flow from TextFieldState private val textFlow = snapshotFlow { textFieldState.text.toString() } @@ -48,13 +57,34 @@ class ChatInputViewModel( val uiState: StateFlow = combine( textFlow, suggestions, - chatRepository.activeChannel - ) { text, suggestions, activeChannel -> + chatRepository.activeChannel, + chatRepository.activeChannel.flatMapLatest { channel -> + if (channel == null) flowOf(ConnectionState.DISCONNECTED) + else chatRepository.getConnectionState(channel) + }, + combine(preferenceStore.isLoggedInFlow, isReplying) { loggedIn, replying -> loggedIn to replying } + ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, isReplying) -> + val inputState = when (connectionState) { + ConnectionState.CONNECTED -> when { + isReplying -> InputState.Replying + else -> InputState.Default + } + ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn + ConnectionState.DISCONNECTED -> InputState.Disconnected + } + + val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn + val enabled = isLoggedIn && connectionState == ConnectionState.CONNECTED + ChatInputUiState( text = text, - canSend = text.isNotBlank() && activeChannel != null, + canSend = canSend, + enabled = enabled, suggestions = suggestions, - activeChannel = activeChannel + activeChannel = activeChannel, + connectionState = connectionState, + isLoggedIn = isLoggedIn, + inputState = inputState ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) @@ -69,6 +99,10 @@ class ChatInputViewModel( } } + fun setReplying(replying: Boolean) { + _isReplying.value = replying + } + fun insertText(text: String) { textFieldState.edit { append(text) @@ -119,6 +153,10 @@ class ChatInputViewModel( data class ChatInputUiState( val text: String = "", val canSend: Boolean = false, + val enabled: Boolean = false, val suggestions: List = emptyList(), - val activeChannel: UserName? = null + val activeChannel: UserName? = null, + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, + val isLoggedIn: Boolean = false, + val inputState: InputState = InputState.Disconnected ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index 3b152a59d..b9cae7c36 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -1,6 +1,15 @@ package com.flxrs.dankchat.main.compose +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.layout.Column import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications @@ -22,6 +31,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.flxrs.dankchat.R +private sealed interface AppBarMenu { + data object Main : AppBarMenu + data object Account : AppBarMenu + data object Channel : AppBarMenu + data object Upload : AppBarMenu + data object More : AppBarMenu +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainAppBar( @@ -46,11 +63,7 @@ fun MainAppBar( onOpenSettings: () -> Unit, modifier: Modifier = Modifier ) { - var showMenu by remember { mutableStateOf(false) } - var showChannelMenu by remember { mutableStateOf(false) } - var showAccountMenu by remember { mutableStateOf(false) } - var showUploadMenu by remember { mutableStateOf(false) } - var showMoreMenu by remember { mutableStateOf(false) } + var currentMenu by remember { mutableStateOf(null) } TopAppBar( title = { Text("DankChat") }, @@ -76,7 +89,7 @@ fun MainAppBar( } // Overflow menu - IconButton(onClick = { showMenu = true }) { + IconButton(onClick = { currentMenu = AppBarMenu.Main }) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = stringResource(R.string.more) @@ -84,192 +97,199 @@ fun MainAppBar( } DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } + expanded = currentMenu != null, + onDismissRequest = { currentMenu = null } ) { - // Login/Account section - if (!isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.login)) }, - onClick = { - onLogin() - showMenu = false - } - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.account)) }, - onClick = { - showAccountMenu = true - } - ) + 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 = "MenuTransition" + ) { menu -> + Column { + when (menu) { + AppBarMenu.Main -> { + if (!isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.login)) }, + onClick = { + onLogin() + currentMenu = null + } + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.account)) }, + onClick = { currentMenu = AppBarMenu.Account } + ) + } - DropdownMenu( - expanded = showAccountMenu, - onDismissRequest = { showAccountMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.relogin)) }, - onClick = { - onRelogin() - showAccountMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.logout)) }, - onClick = { - onLogout() - showAccountMenu = false - showMenu = false - } - ) - } - } + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_channels)) }, + onClick = { + onManageChannels() + currentMenu = null + } + ) - // Manage channels - DropdownMenuItem( - text = { Text(stringResource(R.string.manage_channels)) }, - onClick = { - onManageChannels() - showMenu = false - } - ) + DropdownMenuItem( + text = { Text(stringResource(R.string.channel)) }, + onClick = { currentMenu = AppBarMenu.Channel } + ) - // Channel submenu - DropdownMenuItem( - text = { Text(stringResource(R.string.channel)) }, - onClick = { - showChannelMenu = true - } - ) + DropdownMenuItem( + text = { Text(stringResource(R.string.upload_media)) }, + onClick = { currentMenu = AppBarMenu.Upload } + ) - DropdownMenu( - expanded = showChannelMenu, - onDismissRequest = { showChannelMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.open_channel)) }, - onClick = { - onOpenChannel() - showChannelMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_channel)) }, - onClick = { - onRemoveChannel() - showChannelMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_channel)) }, - onClick = { - onReportChannel() - showChannelMenu = false - showMenu = false - } - ) - if (isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_channel)) }, - onClick = { - onBlockChannel() - showChannelMenu = false - showMenu = false + DropdownMenuItem( + text = { Text(stringResource(R.string.more)) }, + onClick = { currentMenu = AppBarMenu.More } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.settings)) }, + onClick = { + onOpenSettings() + currentMenu = null + } + ) } - ) - } - } - // Upload media submenu - DropdownMenuItem( - text = { Text(stringResource(R.string.upload_media)) }, - onClick = { - showUploadMenu = true - } - ) + AppBarMenu.Account -> { + SubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.relogin)) }, + onClick = { + onRelogin() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.logout)) }, + onClick = { + onLogout() + currentMenu = null + } + ) + } - DropdownMenu( - expanded = showUploadMenu, - onDismissRequest = { showUploadMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.take_picture)) }, - onClick = { - onCaptureImage() - showUploadMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.record_video)) }, - onClick = { - onCaptureVideo() - showUploadMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.choose_media)) }, - onClick = { - onChooseMedia() - showUploadMenu = false - showMenu = false - } - ) - } + AppBarMenu.Channel -> { + SubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.open_channel)) }, + onClick = { + onOpenChannel() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_channel)) }, + onClick = { + onRemoveChannel() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_channel)) }, + onClick = { + onReportChannel() + currentMenu = null + } + ) + if (isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_channel)) }, + onClick = { + onBlockChannel() + currentMenu = null + } + ) + } + } - // More submenu - DropdownMenuItem( - text = { Text(stringResource(R.string.more)) }, - onClick = { - showMoreMenu = true - } - ) + AppBarMenu.Upload -> { + SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.take_picture)) }, + onClick = { + onCaptureImage() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.record_video)) }, + onClick = { + onCaptureVideo() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.choose_media)) }, + onClick = { + onChooseMedia() + currentMenu = null + } + ) + } - DropdownMenu( - expanded = showMoreMenu, - onDismissRequest = { showMoreMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.reload_emotes)) }, - onClick = { - onReloadEmotes() - showMoreMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.reconnect)) }, - onClick = { - onReconnect() - showMoreMenu = false - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.clear_chat)) }, - onClick = { - onClearChat() - showMoreMenu = false - showMenu = false + AppBarMenu.More -> { + SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.reload_emotes)) }, + onClick = { + onReloadEmotes() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.reconnect)) }, + onClick = { + onReconnect() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.clear_chat)) }, + onClick = { + onClearChat() + currentMenu = null + } + ) + } + + null -> {} } - ) - } - - // Settings - DropdownMenuItem( - text = { Text(stringResource(R.string.settings)) }, - onClick = { - onOpenSettings() - showMenu = false } - ) + } } }, modifier = modifier ) } + +@Composable +private fun SubMenuHeader(title: String, onBack: () -> Unit) { + DropdownMenuItem( + text = { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = onBack + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt new file mode 100644 index 000000000..07e0a4f1c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt @@ -0,0 +1,16 @@ +package com.flxrs.dankchat.main.compose + +import com.flxrs.dankchat.main.MainEvent +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/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 7c242cebd..677308133 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -34,18 +34,40 @@ import com.flxrs.dankchat.preferences.components.DankBackground import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel +import org.koin.compose.koinInject +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.AlertDialog +import androidx.compose.ui.res.stringResource +import com.flxrs.dankchat.R +import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog - +import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog import com.flxrs.dankchat.chat.user.compose.UserPopupDialog +import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.DisplayName +import androidx.compose.ui.platform.LocalUriHandler +import androidx.navigation.NavController +import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog +import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsState +import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel +import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.main.compose.sheets.MentionSheet +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import org.koin.core.parameter.parametersOf + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun MainScreen( + navController: NavController, isLoggedIn: Boolean, onNavigateToSettings: () -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -53,18 +75,14 @@ fun MainScreen( onLogin: () -> Unit, onRelogin: () -> Unit, onLogout: () -> Unit, - onManageChannels: () -> Unit, onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, + onOpenUrl: (String) -> Unit, onReloadEmotes: () -> Unit, onReconnect: () -> Unit, - onClearChat: () -> Unit, onCaptureImage: () -> Unit, onCaptureVideo: () -> Unit, onChooseMedia: () -> Unit, - onAddChannel: () -> Unit, modifier: Modifier = Modifier ) { // Scoped ViewModels - each handles one concern @@ -74,10 +92,122 @@ fun MainScreen( val channelPagerViewModel: ChannelPagerViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() + val mainEventBus: MainEventBus = koinInject() val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current var showAddChannelDialog by remember { mutableStateOf(false) } + var showManageChannelsDialog by remember { mutableStateOf(false) } + var showLogoutDialog by remember { mutableStateOf(false) } + var showRemoveChannelDialog by remember { mutableStateOf(false) } + var showBlockChannelDialog by remember { mutableStateOf(false) } + var showClearChatDialog by remember { mutableStateOf(false) } var userPopupParams by remember { mutableStateOf(null) } + var messageOptionsParams by remember { mutableStateOf(null) } + var emoteInfoEmotes by remember { mutableStateOf?>(null) } + + val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() + + LaunchedEffect(fullScreenSheetState) { + chatInputViewModel.setReplying(fullScreenSheetState is FullScreenSheetState.Replies) + } + + LaunchedEffect(Unit) { + mainEventBus.events.collect { event -> + if (event is MainEvent.LogOutRequested) { + showLogoutDialog = true + } + } + } + + when (val state = fullScreenSheetState) { + is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Mention -> { + MentionSheet( + initialisWhisperTab = false, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + } + ) + } + is FullScreenSheetState.Whisper -> { + MentionSheet( + initialisWhisperTab = true, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + } + ) + } + is FullScreenSheetState.Replies -> { + RepliesSheet( + rootMessageId = state.replyMessageId, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn + ) + } + ) + } + } + + val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value + val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel if (showAddChannelDialog) { AddChannelDialog( @@ -89,9 +219,165 @@ fun MainScreen( ) } - if (userPopupParams != null) { + if (showManageChannelsDialog) { + val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() + ManageChannelsDialog( + channels = channels, + onRemoveChannel = channelManagementViewModel::removeChannel, + onRenameChannel = channelManagementViewModel::renameChannel, + onReorder = channelManagementViewModel::reorderChannels, + onDismiss = { showManageChannelsDialog = false } + ) + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text(stringResource(R.string.confirm_logout_title)) }, + text = { Text(stringResource(R.string.confirm_logout_message)) }, + confirmButton = { + TextButton( + onClick = { + onLogout() + showLogoutDialog = false + } + ) { + Text(stringResource(R.string.confirm_logout_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showRemoveChannelDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = { showRemoveChannelDialog = false }, + title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, + text = { Text(stringResource(R.string.confirm_channel_removal_message_named, activeChannel)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.removeChannel(activeChannel) + showRemoveChannelDialog = false + } + ) { + Text(stringResource(R.string.confirm_channel_removal_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showRemoveChannelDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showBlockChannelDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = { showBlockChannelDialog = false }, + title = { Text(stringResource(R.string.confirm_channel_block_title)) }, + text = { Text(stringResource(R.string.confirm_channel_block_message_named, activeChannel)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.blockChannel(activeChannel) + showBlockChannelDialog = false + } + ) { + Text(stringResource(R.string.confirm_user_block_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showBlockChannelDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showClearChatDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = { showClearChatDialog = false }, + title = { Text(stringResource(R.string.clear_chat)) }, + text = { Text(stringResource(R.string.confirm_user_delete_message)) }, // Reuse message deletion text or find better one + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.clearChat(activeChannel) + showClearChatDialog = false + } + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { showClearChatDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + messageOptionsParams?.let { params -> + val viewModel: MessageOptionsComposeViewModel = koinViewModel( + key = params.messageId, + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } + ) + val state by viewModel.state.collectAsStateWithLifecycle() + (state as? MessageOptionsState.Found)?.let { s -> + MessageOptionsDialog( + messageId = s.messageId, + channel = params.channel?.value, + fullMessage = params.fullMessage, + canModerate = s.canModerate, + canReply = s.canReply, + hasReplyThread = s.hasReplyThread, + onReply = { + sheetNavigationViewModel.openReplies(s.messageId) + }, + onViewThread = { + sheetNavigationViewModel.openReplies(s.rootThreadId) + }, + onCopy = { /* TODO: Implement copy to clipboard */ }, + onMoreActions = { /* TODO: Implement more actions */ }, + onDelete = viewModel::deleteMessage, + onTimeout = viewModel::timeoutUser, + onBan = viewModel::banUser, + onUnban = viewModel::unbanUser, + onDismiss = { messageOptionsParams = null } + ) + } + } + + emoteInfoEmotes?.let { emotes -> + val viewModel: EmoteInfoComposeViewModel = koinViewModel( + key = emotes.joinToString { it.id }, + parameters = { parametersOf(emotes) } + ) + EmoteInfoDialog( + items = viewModel.items, + onUseEmote = { chatInputViewModel.insertText("$it ") }, + onCopyEmote = { /* TODO: copy to clipboard */ }, + onOpenLink = { onOpenUrl(it) }, + onDismiss = { emoteInfoEmotes = null } + ) + } + + userPopupParams?.let { params -> + val viewModel: UserPopupComposeViewModel = koinViewModel( + key = "${params.targetUserId}${params.channel?.value.orEmpty()}", + parameters = { parametersOf(params) } + ) + val state by viewModel.userPopupState.collectAsStateWithLifecycle() UserPopupDialog( - params = userPopupParams!!, + state = state, + badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }, + onBlockUser = viewModel::blockUser, + onUnblockUser = viewModel::unblockUser, onDismiss = { userPopupParams = null }, onMention = { name, _ -> chatInputViewModel.insertText("@$name ") @@ -107,7 +393,6 @@ fun MainScreen( ) } - val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() val inputState by chatInputViewModel.uiState.collectAsStateWithLifecycle() @@ -122,7 +407,7 @@ fun MainScreen( val focusManager = LocalFocusManager.current val imeAnimationTarget = WindowInsets.imeAnimationTarget val isKeyboardVisible = WindowInsets.isImeVisible && - imeAnimationTarget.getBottom(density) > 0 // Target open = keyboard will be visible + imeAnimationTarget.getBottom(density) > 0 LaunchedEffect(isKeyboardVisible) { if (!isKeyboardVisible) { @@ -159,15 +444,21 @@ fun MainScreen( onOpenMentions = { sheetNavigationViewModel.openMentions() }, onLogin = onLogin, onRelogin = onRelogin, - onLogout = onLogout, - onManageChannels = onManageChannels, + onLogout = { showLogoutDialog = true }, + onManageChannels = { showManageChannelsDialog = true }, onOpenChannel = onOpenChannel, - onRemoveChannel = onRemoveChannel, + onRemoveChannel = { showRemoveChannelDialog = true }, onReportChannel = onReportChannel, - onBlockChannel = onBlockChannel, - onReloadEmotes = onReloadEmotes, - onReconnect = onReconnect, - onClearChat = onClearChat, + onBlockChannel = { showBlockChannelDialog = true }, + onReloadEmotes = { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + onReloadEmotes() + }, + onReconnect = { + channelManagementViewModel.reconnect() + onReconnect() + }, + onClearChat = { showClearChatDialog = true }, onCaptureImage = onCaptureImage, onCaptureVideo = onCaptureVideo, onChooseMedia = onChooseMedia, @@ -221,7 +512,6 @@ fun MainScreen( ChatComposable( channel = channel, onUserClick = { userId, userName, displayName, channel, badges, _ -> - // Always open popup for now (long press handled same as click) userPopupParams = UserPopupStateParams( targetUserId = userId?.let { UserId(it) } ?: UserId(""), targetUserName = UserName(userName), @@ -230,8 +520,18 @@ fun MainScreen( badges = badges.map { it.badge } ) }, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + }, onReplyClick = { replyMessageId -> sheetNavigationViewModel.openReplies(replyMessageId) } @@ -242,6 +542,8 @@ fun MainScreen( // Input - State from ChatInputViewModel ChatInputLayout( textFieldState = chatInputViewModel.textFieldState, + inputState = inputState.inputState, + enabled = inputState.enabled, canSend = inputState.canSend, onSend = chatInputViewModel::sendMessage, onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, @@ -252,7 +554,7 @@ fun MainScreen( } } - // Suggestion dropdown floats above input field (only when keyboard visible) + // Suggestion dropdown floats above input field if (isKeyboardVisible) { SuggestionDropdown( suggestions = inputState.suggestions, @@ -265,4 +567,4 @@ fun MainScreen( } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt new file mode 100644 index 000000000..1450cd2fb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt @@ -0,0 +1,97 @@ +package com.flxrs.dankchat.main.compose.dialogs + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +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.TextFieldValue +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.preferences.model.ChannelWithRename + +@Composable +fun EditChannelDialog( + channelWithRename: ChannelWithRename, + onRename: (UserName, String?) -> Unit, + onDismiss: () -> Unit, +) { + var renameText by remember { + val initialText = channelWithRename.rename?.value ?: "" + mutableStateOf( + TextFieldValue( + text = initialText, + selection = TextRange(initialText.length) + ) + ) + } + val focusRequester = remember { FocusRequester() } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.edit_dialog_title)) }, + text = { + OutlinedTextField( + value = renameText, + onValueChange = { renameText = it }, + label = { Text(channelWithRename.channel.value) }, + placeholder = { Text(channelWithRename.channel.value) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + onRename(channelWithRename.channel, renameText.text) + onDismiss() + }), + trailingIcon = if (renameText.text.isNotEmpty()) { + { + IconButton(onClick = { + onRename(channelWithRename.channel, null) + onDismiss() + }) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.clear) + ) + } + } + } else null, + modifier = Modifier.focusRequester(focusRequester) + ) + }, + confirmButton = { + TextButton( + onClick = { + onRename(channelWithRename.channel, renameText.text) + onDismiss() + } + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt new file mode 100644 index 000000000..28b265ae3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt @@ -0,0 +1,179 @@ +package com.flxrs.dankchat.main.compose.dialogs + +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.foundation.pager.HorizontalPager +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.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.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.chat.emote.EmoteSheetItem +import kotlinx.coroutines.launch + +import androidx.compose.material3.PrimaryTabRow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmoteInfoDialog( + items: List, + onUseEmote: (String) -> Unit, + onCopyEmote: (String) -> Unit, + onOpenLink: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { items.size }) + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.fillMaxWidth()) { + if (items.size > 1) { + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + items.forEachIndexed { index, item -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { pagerState.animateScrollToPage(index) } + }, + text = { Text(item.name) } + ) + } + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth() + ) { page -> + val item = items[page] + EmoteInfoContent( + item = item, + onUseEmote = { + onUseEmote(item.name) + onDismiss() + }, + onCopyEmote = { + onCopyEmote(item.name) + onDismiss() + }, + onOpenLink = { + onOpenLink(item.providerUrl) + onDismiss() + } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun EmoteInfoContent( + item: EmoteSheetItem, + onUseEmote: () -> Unit, + onCopyEmote: () -> Unit, + onOpenLink: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = item.imageUrl, + contentDescription = stringResource(R.string.emote_sheet_image_description), + modifier = Modifier.size(128.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) { + 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 + ) + } + if (item.isZeroWidth) { + Text( + text = stringResource(R.string.emote_sheet_zero_width_emote), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + + 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) + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt new file mode 100644 index 000000000..527856c70 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt @@ -0,0 +1,216 @@ +package com.flxrs.dankchat.main.compose.dialogs + +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.Box +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +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 java.util.Collections + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageChannelsDialog( + channels: List, + onRemoveChannel: (UserName) -> Unit, + onRenameChannel: (UserName, String?) -> Unit, + onReorder: (List) -> Unit, + onDismiss: () -> Unit, +) { + var channelToDelete by remember { mutableStateOf(null) } + var channelToEdit by remember { mutableStateOf(null) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Text( + text = stringResource(R.string.manage_channels), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + state = rememberLazyListState() + ) { + itemsIndexed(channels, key = { _, item -> item.channel.value }) { index, channelWithRename -> + ChannelItem( + channelWithRename = channelWithRename, + onEdit = { channelToEdit = channelWithRename }, + onDelete = { channelToDelete = channelWithRename.channel }, + onMoveUp = if (index > 0) { + { + val newList = channels.toMutableList() + Collections.swap(newList, index, index - 1) + onReorder(newList) + } + } else null, + onMoveDown = if (index < channels.size - 1) { + { + val newList = channels.toMutableList() + Collections.swap(newList, index, index + 1) + onReorder(newList) + } + } else null + ) + } + + if (channels.isEmpty()) { + item { + Text( + text = stringResource(R.string.no_channels_added), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + } + } + } + } + } + + if (channelToDelete != null) { + AlertDialog( + onDismissRequest = { channelToDelete = null }, + title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, + text = { Text(stringResource(R.string.confirm_channel_removal_message)) }, + confirmButton = { + TextButton( + onClick = { + channelToDelete?.let(onRemoveChannel) + channelToDelete = null + } + ) { + Text(stringResource(R.string.confirm_channel_removal_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { channelToDelete = null }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + channelToEdit?.let { channel -> + EditChannelDialog( + channelWithRename = channel, + onRename = onRenameChannel, + onDismiss = { channelToEdit = null } + ) + } +} + +@Composable +private fun ChannelItem( + channelWithRename: ChannelWithRename, + onEdit: () -> Unit, + onDelete: () -> Unit, + onMoveUp: (() -> Unit)?, + onMoveDown: (() -> Unit)? +) { + var showReorderMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box { + IconButton(onClick = { showReorderMenu = true }) { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + DropdownMenu( + expanded = showReorderMenu, + onDismissRequest = { showReorderMenu = false } + ) { + if (onMoveUp != null) { + DropdownMenuItem( + text = { Text("Move Up") }, + onClick = { + onMoveUp() + showReorderMenu = false + } + ) + } + if (onMoveDown != null) { + DropdownMenuItem( + text = { Text("Move Down") }, + onClick = { + onMoveDown() + showReorderMenu = false + } + ) + } + } + } + + 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(8.dp) + ) + + 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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt new file mode 100644 index 000000000..a0c8fec55 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt @@ -0,0 +1,250 @@ +package com.flxrs.dankchat.main.compose.dialogs + +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.width +import androidx.compose.material.icons.Icons +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.MoreVert +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageOptionsDialog( + messageId: String, + channel: String?, + fullMessage: String, + canModerate: Boolean, + canReply: Boolean, + hasReplyThread: Boolean, + onReply: () -> Unit, + onViewThread: () -> Unit, + onCopy: () -> Unit, + onMoreActions: () -> Unit, + onDelete: () -> Unit, + onTimeout: (index: Int) -> Unit, + onBan: () -> Unit, + onUnban: () -> Unit, + onDismiss: () -> Unit, +) { + var showTimeoutDialog by remember { mutableStateOf(false) } + var showBanDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + if (canReply) { + MessageOptionItem( + icon = Icons.AutoMirrored.Filled.Reply, + text = stringResource(R.string.message_reply), + onClick = { + onReply() + onDismiss() + } + ) + } + if (hasReplyThread) { + MessageOptionItem( + icon = Icons.AutoMirrored.Filled.Reply, // Using same icon for thread view + text = stringResource(R.string.message_view_thread), + onClick = { + onViewThread() + onDismiss() + } + ) + } + + MessageOptionItem( + icon = Icons.Default.ContentCopy, + text = stringResource(R.string.message_copy), + onClick = { + onCopy() + onDismiss() + } + ) + + MessageOptionItem( + icon = Icons.Default.MoreVert, + text = stringResource(R.string.message_more_actions), + onClick = { + onMoreActions() + onDismiss() + } + ) + + if (canModerate) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + MessageOptionItem( + icon = Icons.Default.Timer, + text = stringResource(R.string.user_popup_timeout), + onClick = { showTimeoutDialog = true } + ) + + MessageOptionItem( + icon = Icons.Default.Delete, + text = stringResource(R.string.user_popup_delete), + onClick = { showDeleteDialog = true } + ) + + MessageOptionItem( + icon = Icons.Default.Gavel, + text = stringResource(R.string.user_popup_ban), + onClick = { showBanDialog = true } + ) + + MessageOptionItem( + icon = Icons.Default.Gavel, // Using same icon for unban + text = stringResource(R.string.user_popup_unban), + onClick = { + onUnban() + onDismiss() + } + ) + } + } + } + + if (showTimeoutDialog) { + TimeoutConfirmDialog( + onConfirm = { index -> + onTimeout(index) + showTimeoutDialog = false + onDismiss() + }, + onDismiss = { showTimeoutDialog = false } + ) + } + + if (showBanDialog) { + AlertDialog( + onDismissRequest = { showBanDialog = false }, + title = { Text(stringResource(R.string.confirm_user_ban_title)) }, + text = { Text(stringResource(R.string.confirm_user_ban_message)) }, + confirmButton = { + TextButton(onClick = { + onBan() + showBanDialog = false + onDismiss() + }) { + Text(stringResource(R.string.confirm_user_ban_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showBanDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.confirm_user_delete_title)) }, + text = { Text(stringResource(R.string.confirm_user_delete_message)) }, + confirmButton = { + TextButton(onClick = { + onDelete() + showDeleteDialog = false + onDismiss() + }) { + Text(stringResource(R.string.confirm_user_delete_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } +} + +@Composable +private fun MessageOptionItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + onClick: () -> Unit +) { + ListItem( + headlineContent = { Text(text) }, + leadingContent = { Icon(icon, contentDescription = null) }, + modifier = Modifier.clickable(onClick = onClick) + ) +} + +@Composable +private fun TimeoutConfirmDialog( + onConfirm: (Int) -> Unit, + onDismiss: () -> Unit +) { + val choices = stringArrayResource(R.array.timeout_entries) + var sliderPosition by remember { mutableFloatStateOf(0f) } + val currentIndex = sliderPosition.toInt() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.confirm_user_timeout_title)) }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = choices[currentIndex], + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + valueRange = 0f..(choices.size - 1).toFloat(), + steps = choices.size - 2 + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(currentIndex) }) { + Text(stringResource(R.string.confirm_user_timeout_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt new file mode 100644 index 000000000..3423dd22e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -0,0 +1,96 @@ +package com.flxrs.dankchat.main.compose.sheets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.mention.MentionViewModel +import com.flxrs.dankchat.chat.mention.compose.MentionComposable +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MentionSheet( + initialisWhisperTab: Boolean, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + 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, +) { + val viewModel: MentionViewModel = koinViewModel() + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = if (initialisWhisperTab) 1 else 0, + pageCount = { 2 } + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxSize() + ) { + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text(stringResource(R.string.mentions_title)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + } + ) + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.mentions)) } + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.whispers)) } + ) + } + } + } + ) { paddingValues -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { page -> + MentionComposable( + mentionViewModel = viewModel, + appearanceSettingsDataStore = appearanceSettingsDataStore, + isWhisperTab = page == 1, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt new file mode 100644 index 000000000..b50c1809d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -0,0 +1,83 @@ +package com.flxrs.dankchat.main.compose.sheets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.replies.compose.RepliesComposable +import com.flxrs.dankchat.chat.replies.compose.RepliesComposeViewModel +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RepliesSheet( + rootMessageId: String, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + onDismiss: () -> Unit, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, +) { + val viewModel: RepliesComposeViewModel = koinViewModel( + key = rootMessageId, + parameters = { parametersOf(rootMessageId) } + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxSize() + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.replies_title)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + } + ) + } + ) { paddingValues -> + // Use local ViewModel version of RepliesComposable or similar + // For simplicity, I'll call RepliesComposable but I might need to adjust it to take the new VM + // Or just inline its logic here since we have the new VM. + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value + + com.flxrs.dankchat.chat.compose.ChatScreen( + messages = when (uiState) { + is com.flxrs.dankchat.chat.replies.RepliesUiState.Found -> uiState.items + else -> emptyList() + }, + fontSize = appearanceSettings.fontSize.toFloat(), + modifier = Modifier.padding(paddingValues).fillMaxSize(), + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = { /* no-op */ } + ) + + if (uiState is com.flxrs.dankchat.chat.replies.RepliesUiState.NotFound) { + androidx.compose.runtime.LaunchedEffect(Unit) { + onDismiss() + } + } + } + } +} 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..5172df84d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -87,6 +87,18 @@ class DankChatPreferenceStore( val secretDankerModeClicks: Int = SECRET_DANKER_MODE_CLICKS + val isLoggedInFlow: Flow = callbackFlow { + send(isLoggedIn) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == LOGGED_IN_KEY) { + trySend(isLoggedIn) + } + } + + dankChatPreferences.registerOnSharedPreferenceChangeListener(listener) + awaitClose { dankChatPreferences.unregisterOnSharedPreferenceChangeListener(listener) } + } + val currentUserAndDisplayFlow: Flow> = callbackFlow { send(userName to displayName) val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> 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 index d7da086ee..c98a400b7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt @@ -76,75 +76,9 @@ class AboutFragment : Fragment() { 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()), - ) - } - ) - } - } - } + AboutScreen( + onBackPressed = { navController.popBackStack() }, + ) } } } 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..4b70b6042 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -0,0 +1,120 @@ +package com.flxrs.dankchat.preferences.about + +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.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 com.flxrs.dankchat.R +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.Dispatchers +import kotlinx.coroutines.withContext +import sh.calvin.autolinktext.TextRuleDefaults +import sh.calvin.autolinktext.annotateString + +@Composable +fun AboutScreen( + onBackPressed: () -> Unit, +) { + 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 = onBackPressed, + content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.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/appearance/AppearanceSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt index 12f18fc5b..c6f8254ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt @@ -93,14 +93,8 @@ class AppearanceSettingsFragment : Fragment() { 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) }, + AppearanceSettingsScreen( onBackPressed = { findNavController().popBackStack() } ) } @@ -108,239 +102,3 @@ class AppearanceSettingsFragment : Fragment() { } } } - -@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() - // minSdk 30 always supports light mode and system dark mode - val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) - val lightThemeTitle = 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 { - 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..60a12f0ab --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -0,0 +1,313 @@ +package com.flxrs.dankchat.preferences.appearance + +import android.app.Activity +import android.content.Context +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.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.core.app.ActivityCompat +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.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 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( + onBackPressed: () -> Unit, +) { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value + + AppearanceSettingsContent( + settings = settings, + onInteraction = { viewModel.onInteraction(it) }, + onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, + onBackPressed = onBackPressed + ) +} + +@Composable +private fun AppearanceSettingsContent( + 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() + // minSdk 30 always supports light mode and system dark mode + val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) + val lightThemeTitle = 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 { + 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/developer/DeveloperSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt index 195fe74b5..7a1f4794b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt @@ -109,36 +109,8 @@ class DeveloperSettingsFragment : Fragment() { 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) }, + DeveloperSettingsScreen( onBackPressed = { findNavController().popBackStack() }, ) } @@ -146,326 +118,3 @@ class DeveloperSettingsFragment : Fragment() { } } } - -@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)) }, - ) - SwitchPreferenceItem( - title = "Use Compose Chat UI", - summary = "Enable new Compose-based chat interface (experimental)", - isChecked = settings.useComposeChatUi, - onClick = { onInteraction(DeveloperSettingsInteraction.UseComposeChatUi(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..db53b5f2b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -0,0 +1,448 @@ +package com.flxrs.dankchat.preferences.developer + +import android.content.ClipData +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.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.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.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.lifecycle.compose.collectAsStateWithLifecycle +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.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( + onBackPressed: () -> 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.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + ProcessPhoenix.triggerRebirth(context) + } + } + } + } + } + + DeveloperSettingsContent( + settings = settings, + snackbarHostState = snackbarHostState, + onInteraction = { viewModel.onInteraction(it) }, + onBackPressed = onBackPressed, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeveloperSettingsContent( + settings: DeveloperSettings, + snackbarHostState: 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)) }, + ) + SwitchPreferenceItem( + title = "Use Compose Chat UI", + summary = "Enable new Compose-based chat interface (experimental)", + isChecked = settings.useComposeChatUi, + onClick = { onInteraction(DeveloperSettingsInteraction.UseComposeChatUi(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() + } + } +} + +@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)) }) { + 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( + 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) } + 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(), + 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() + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@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/overview/OverviewSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt index 18a68a920..fbb555eb4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt @@ -84,7 +84,7 @@ class OverviewSettingsFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { DankChatTheme { - OverviewSettings( + OverviewSettingsScreen( isLoggedIn = dankChatPreferences.isLoggedIn, hasChangelog = DankChatVersion.HAS_CHANGELOG, onBackPressed = { navController.popBackStack() }, @@ -108,123 +108,3 @@ class OverviewSettingsFragment : Fragment() { } } -@Composable -private fun OverviewSettings( - isLoggedIn: Boolean, - hasChangelog: Boolean, - onBackPressed: () -> Unit, - onLogoutRequested: () -> Unit, - onNavigateRequested: (id: Int) -> Unit, -) { - 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.settings)) }, - navigationIcon = { - IconButton( - onClick = onBackPressed, - 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()), - ) { - PreferenceItem( - title = stringResource(R.string.preference_appearance_header), - icon = Icons.Default.Palette, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment) }, - ) - PreferenceItem( - title = stringResource(R.string.preference_highlights_ignores_header), - icon = Icons.Default.NotificationsActive, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment) }, - ) - PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_chatSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_streamsSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_toolsSettingsFragment) - }) - PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_developerSettingsFragment) - }) - - AnimatedVisibility(hasChangelog) { - PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_changelogSheetFragment) - }) - } - - PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogoutRequested) - SecretDankerModeTrigger { - PreferenceCategoryWithSummary( - title = { - PreferenceCategoryTitle( - text = stringResource(R.string.preference_about_header), - modifier = Modifier.dankClickable(), - ) - }, - ) { - 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) - } - } - PreferenceSummary(annotated, Modifier.padding(top = 16.dp)) - } - } - NavigationBarSpacer() - } - } -} - -@Composable -@PreviewDynamicColors -@PreviewLightDark -private fun OverviewSettingsPreview() { - DankChatTheme { - OverviewSettings( - isLoggedIn = false, - hasChangelog = true, - onBackPressed = { }, - onLogoutRequested = { }, - onNavigateRequested = { }, - ) - } -} - -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/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt new file mode 100644 index 000000000..a118051be --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -0,0 +1,169 @@ +package com.flxrs.dankchat.preferences.overview + +import androidx.compose.animation.AnimatedVisibility +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.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.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.Construction +import androidx.compose.material.icons.filled.DeveloperMode +import androidx.compose.material.icons.filled.FiberNew +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PlayArrow +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.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.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 com.flxrs.dankchat.BuildConfig +import com.flxrs.dankchat.R +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.utils.compose.buildClickableAnnotation +import com.flxrs.dankchat.utils.compose.buildLinkAnnotation + +private const val GITHUB_URL = "https://github.com/flex3r/dankchat" +private const val TWITCH_TOS_URL = "https://www.twitch.tv/p/terms-of-service" + +@Composable +fun OverviewSettingsScreen( + isLoggedIn: Boolean, + hasChangelog: Boolean, + onBackPressed: () -> Unit, + onLogoutRequested: () -> Unit, + onNavigateRequested: (id: Int) -> Unit, +) { + 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.settings)) }, + navigationIcon = { + IconButton( + onClick = onBackPressed, + 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()), + ) { + PreferenceItem( + title = stringResource(R.string.preference_appearance_header), + icon = Icons.Default.Palette, + onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment) }, + ) + PreferenceItem( + title = stringResource(R.string.preference_highlights_ignores_header), + icon = Icons.Default.NotificationsActive, + onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment) }, + ) + PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_chatSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_streamsSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_toolsSettingsFragment) + }) + PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_developerSettingsFragment) + }) + + AnimatedVisibility(hasChangelog) { + PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { + onNavigateRequested(R.id.action_overviewSettingsFragment_to_changelogSheetFragment) + }) + } + + PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogoutRequested) + SecretDankerModeTrigger { + PreferenceCategoryWithSummary( + title = { + PreferenceCategoryTitle( + text = stringResource(R.string.preference_about_header), + modifier = Modifier.dankClickable(), + ) + }, + ) { + 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) + } + } + PreferenceSummary(annotated, Modifier.padding(top = 16.dp)) + } + } + NavigationBarSpacer() + } + } +} + +@Composable +@PreviewDynamicColors +@PreviewLightDark +private fun OverviewSettingsPreview() { + DankChatTheme { + OverviewSettingsScreen( + isLoggedIn = false, + hasChangelog = true, + onBackPressed = { }, + onLogoutRequested = { }, + onNavigateRequested = { }, + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt index b0c4eb8a7..0732e6579 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt @@ -61,13 +61,8 @@ class StreamsSettingsFragment : Fragment() { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value - DankChatTheme { - StreamsSettings( - settings = settings, - onInteraction = { viewModel.onInteraction(it) }, + StreamsSettingsScreen( onBackPressed = { findNavController().popBackStack() }, ) } @@ -75,79 +70,3 @@ class StreamsSettingsFragment : Fragment() { } } } - -@Composable -private fun StreamsSettings( - settings: StreamsSettings, - onInteraction: (StreamsSettingsInteraction) -> 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_streams_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_fetch_streams_title), - summary = stringResource(R.string.preference_fetch_streams_summary), - isChecked = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.FetchStreams(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_streaminfo_title), - summary = stringResource(R.string.preference_streaminfo_summary), - isChecked = settings.showStreamInfo, - isEnabled = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamInfo(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_streaminfo_category_title), - summary = stringResource(R.string.preference_streaminfo_category_summary), - isChecked = settings.showStreamCategory, - isEnabled = settings.fetchStreams && settings.showStreamInfo, - onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamCategory(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_retain_webview_title), - summary = stringResource(R.string.preference_retain_webview_summary), - isChecked = settings.preventStreamReloads, - isEnabled = settings.fetchStreams, - onClick = { onInteraction(StreamsSettingsInteraction.PreventStreamReloads(it)) }, - ) - - val activity = LocalActivity.current - 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), - summary = stringResource(R.string.preference_pip_summary), - isChecked = settings.enablePiP, - isEnabled = settings.fetchStreams && settings.preventStreamReloads, - onClick = { onInteraction(StreamsSettingsInteraction.EnablePiP(it)) }, - ) - } - NavigationBarSpacer() - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt new file mode 100644 index 000000000..55c483f7b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt @@ -0,0 +1,122 @@ +package com.flxrs.dankchat.preferences.stream + +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.LocalActivity +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.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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.components.NavigationBarSpacer +import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun StreamsSettingsScreen( + onBackPressed: () -> Unit, +) { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value + + StreamsSettingsContent( + settings = settings, + onInteraction = { viewModel.onInteraction(it) }, + onBackPressed = onBackPressed + ) +} + +@Composable +private fun StreamsSettingsContent( + settings: StreamsSettings, + onInteraction: (StreamsSettingsInteraction) -> 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_streams_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_fetch_streams_title), + summary = stringResource(R.string.preference_fetch_streams_summary), + isChecked = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.FetchStreams(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_streaminfo_title), + summary = stringResource(R.string.preference_streaminfo_summary), + isChecked = settings.showStreamInfo, + isEnabled = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamInfo(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_streaminfo_category_title), + summary = stringResource(R.string.preference_streaminfo_category_summary), + isChecked = settings.showStreamCategory, + isEnabled = settings.fetchStreams && settings.showStreamInfo, + onClick = { onInteraction(StreamsSettingsInteraction.ShowStreamCategory(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_retain_webview_title), + summary = stringResource(R.string.preference_retain_webview_summary), + isChecked = settings.preventStreamReloads, + isEnabled = settings.fetchStreams, + onClick = { onInteraction(StreamsSettingsInteraction.PreventStreamReloads(it)) }, + ) + + val activity = LocalActivity.current + 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), + summary = stringResource(R.string.preference_pip_summary), + isChecked = settings.enablePiP, + isEnabled = settings.fetchStreams && settings.preventStreamReloads, + onClick = { onInteraction(StreamsSettingsInteraction.EnablePiP(it)) }, + ) + } + NavigationBarSpacer() + } + } +} From 01f002c8ac79715f5137ce4a8bf189e130b7dc1e Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 009/349] fix(compose): Message rendering, theming, and click handling --- .../dankchat/chat/compose/ChatMessageMapper.kt | 2 +- .../com/flxrs/dankchat/chat/compose/StackedEmote.kt | 5 +++++ .../dankchat/chat/compose/messages/PrivMessage.kt | 13 ++++++++----- .../chat/compose/messages/WhisperAndRedemption.kt | 12 +++++------- .../messages/common/SimpleMessageContainer.kt | 6 ++++-- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 7665040e1..b47cdeb32 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -70,7 +70,7 @@ object ChatMessageMapper { isAlternateBackground: Boolean, ): ChatMessageUiState { val textAlpha = when (importance) { - ChatImportance.SYSTEM -> 0.75f + ChatImportance.SYSTEM -> 1f ChatImportance.DELETED -> 0.5f ChatImportance.REGULAR -> 1f } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt index cd70e3afa..9f4159240 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt @@ -42,6 +42,7 @@ fun StackedEmote( emoteCoordinator: EmoteAnimationCoordinator, modifier: Modifier = Modifier, animateGifs: Boolean = true, + alpha: Float = 1f, onClick: () -> Unit = {}, ) { val context = LocalPlatformContext.current @@ -58,6 +59,7 @@ fun StackedEmote( scaleFactor = scaleFactor, emoteCoordinator = emoteCoordinator, animateGifs = animateGifs, + alpha = alpha, modifier = modifier, onClick = onClick ) @@ -120,6 +122,7 @@ fun StackedEmote( Image( painter = painter, contentDescription = null, + alpha = alpha, modifier = modifier .size(width = widthDp, height = heightDp) .clickable { onClick() } @@ -137,6 +140,7 @@ private fun SingleEmoteDrawable( scaleFactor: Double, emoteCoordinator: EmoteAnimationCoordinator, animateGifs: Boolean, + alpha: Float = 1f, modifier: Modifier = Modifier, onClick: () -> Unit = {}, ) { @@ -186,6 +190,7 @@ private fun SingleEmoteDrawable( Image( painter = painter, contentDescription = null, + alpha = alpha, modifier = modifier .size(width = widthDp, height = heightDp) .clickable { onClick() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 5b99b4243..5cbabb595 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -14,7 +14,7 @@ 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.filled.Reply +import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -23,6 +23,7 @@ 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.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -87,7 +88,7 @@ fun PrivMessageComposable( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Default.Reply, + imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = null, modifier = Modifier.size(16.dp), tint = Color.Gray @@ -194,7 +195,7 @@ private fun PrivMessageText( val textColor = if (message.isAction) { nameColor } else { - defaultTextColor.copy(alpha = message.textAlpha) + defaultTextColor } withStyle(SpanStyle(color = textColor)) { @@ -264,7 +265,9 @@ private fun PrivMessageText( TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .alpha(message.textAlpha), interactionSource = interactionSource, onTextClick = { offset -> // Handle username clicks @@ -312,4 +315,4 @@ private fun PrivMessageText( } } ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 32d2c523b..70ba6039b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -19,6 +19,7 @@ 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.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -65,6 +66,7 @@ fun WhisperMessageComposable( .background(backgroundColor) .indication(interactionSource, ripple()) .padding(horizontal = 8.dp, vertical = 2.dp) + .alpha(message.textAlpha) ) { WhisperMessageText( message = message, @@ -120,11 +122,6 @@ private fun WhisperMessageText( append(" ") // Space between badges } - // Extra space after badges if any badges exist (before sender name) - if (message.badges.isNotEmpty()) { - // Already added spaces, no need for extra - } - // Sender username with click annotation withStyle( SpanStyle( @@ -153,7 +150,7 @@ private fun WhisperMessageText( append(": ") // Message text with emotes - withStyle(SpanStyle(color = defaultTextColor.copy(alpha = message.textAlpha))) { + withStyle(SpanStyle(color = defaultTextColor)) { var currentPos = 0 message.emotes.sortedBy { it.position.first }.forEach { emote -> // Text before emote @@ -285,6 +282,7 @@ fun PointRedemptionMessageComposable( .wrapContentHeight() .background(backgroundColor) .padding(horizontal = 8.dp, vertical = 2.dp) + .alpha(message.textAlpha) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -345,4 +343,4 @@ fun PointRedemptionMessageComposable( ) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt index 5baf1c15e..05e03f3d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -37,12 +38,13 @@ fun SimpleMessageContainer( .wrapContentHeight() .background(bgColor) .padding(vertical = 2.dp) + .alpha(textAlpha) ) { ChatMessageText( text = message, timestamp = timestamp, fontSize = fontSize, - textColor = textColor.copy(alpha = textAlpha), + textColor = textColor, ) } -} +} \ No newline at end of file From bb020a506258685ace59805aed111831caeab5c6 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 010/349] feat(compose): Redesign chat input to bottom panel --- .github/workflows/android.yml | 12 +- .../7.json | 410 ++++++++++++ .../com/flxrs/dankchat/DankChatApplication.kt | 2 + .../com/flxrs/dankchat/chat/ChatAdapter.kt | 8 +- .../com/flxrs/dankchat/chat/ChatFragment.kt | 138 +--- .../com/flxrs/dankchat/chat/ChatViewModel.kt | 33 - .../dankchat/chat/compose/ChatComposable.kt | 2 +- .../chat/compose/ChatComposeViewModel.kt | 12 +- .../chat/compose/ChatMessageMapper.kt | 17 +- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 5 +- .../compose/TextWithMeasuredInlineContent.kt | 34 +- .../chat/compose/messages/PrivMessage.kt | 6 +- .../chat/mention/MentionChatFragment.kt | 50 +- .../dankchat/chat/mention/MentionViewModel.kt | 44 +- .../chat/mention/compose/MentionComposable.kt | 7 +- .../compose/MentionComposeViewModel.kt | 82 +++ .../message/compose/MessageOptionsParams.kt | 1 + .../chat/replies/RepliesChatFragment.kt | 55 +- .../dankchat/chat/replies/RepliesState.kt | 11 +- .../dankchat/chat/replies/RepliesViewModel.kt | 39 +- .../chat/replies/compose/RepliesComposable.kt | 7 +- .../compose/RepliesComposeViewModel.kt | 11 +- .../data/database/DankChatDatabase.kt | 5 +- .../data/database/dao/BadgeHighlightDao.kt | 33 + .../database/entity/BadgeHighlightEntity.kt | 20 + .../data/repo/HighlightsRepository.kt | 168 +++-- .../dankchat/data/repo/chat/ChatRepository.kt | 6 +- .../data/twitch/message/HighlightState.kt | 1 + .../com/flxrs/dankchat/di/DankChatModule.kt | 2 +- .../com/flxrs/dankchat/di/DatabaseModule.kt | 6 + .../dankchat/domain/ChannelDataCoordinator.kt | 47 +- .../dankchat/domain/ChannelDataLoader.kt | 42 +- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 25 +- .../dankchat/login/compose/LoginScreen.kt | 9 +- .../com/flxrs/dankchat/main/MainActivity.kt | 50 +- .../flxrs/dankchat/main/MainDestination.kt | 3 + .../main/compose/ChannelTabViewModel.kt | 64 +- .../dankchat/main/compose/ChatInputLayout.kt | 283 ++++++-- .../main/compose/ChatInputViewModel.kt | 248 +++++-- .../main/compose/EmoteMenuViewModel.kt | 80 +++ .../flxrs/dankchat/main/compose/MainAppBar.kt | 37 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 606 ++++++++++++------ .../main/compose/MainScreenViewModel.kt | 38 ++ .../main/compose/SheetNavigationViewModel.kt | 12 +- .../compose/dialogs/MessageOptionsDialog.kt | 45 +- .../main/compose/dialogs/MoreActionsSheet.kt | 62 ++ .../main/compose/sheets/EmoteMenuSheet.kt | 141 ++++ .../main/compose/sheets/MentionSheet.kt | 122 ++-- .../main/compose/sheets/RepliesSheet.kt | 103 +-- .../notifications/highlights/HighlightItem.kt | 36 ++ .../highlights/HighlightsScreen.kt | 318 ++++++++- .../notifications/highlights/HighlightsTab.kt | 1 + .../highlights/HighlightsViewModel.kt | 31 + .../main/res/values-b+zh+Hant+TW/strings.xml | 4 +- app/src/main/res/values-de-rDE/strings.xml | 12 + app/src/main/res/values-en/strings.xml | 12 + app/src/main/res/values/strings.xml | 16 + gradle/wrapper/gradle-wrapper.properties | 2 +- 58 files changed, 2750 insertions(+), 926 deletions(-) create mode 100644 app/schemas/com.flxrs.dankchat.data.database.DankChatDatabase/7.json create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BadgeHighlightDao.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BadgeHighlightEntity.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MoreActionsSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 684c5283b..c953cc440 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: set up JDK 17 uses: actions/setup-java@v5 with: @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: set up JDK 17 uses: actions/setup-java@v5 with: @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: set up JDK 17 uses: actions/setup-java@v5 with: @@ -68,7 +68,7 @@ jobs: run: bash ./gradlew :app:assembleDebug - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: debug apk path: ${{ github.workspace }}/app/build/outputs/apk/debug/*.apk @@ -97,7 +97,7 @@ jobs: if: (github.event_name == 'push' && github.ref == 'refs/heads/develop') steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: set up JDK 17 uses: actions/setup-java@v5 with: @@ -125,7 +125,7 @@ jobs: SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Signed release apk path: ${{ github.workspace }}/app/build/outputs/apk/release/*.apk diff --git a/app/schemas/com.flxrs.dankchat.data.database.DankChatDatabase/7.json b/app/schemas/com.flxrs.dankchat.data.database.DankChatDatabase/7.json new file mode 100644 index 000000000..e05ab6f92 --- /dev/null +++ b/app/schemas/com.flxrs.dankchat.data.database.DankChatDatabase/7.json @@ -0,0 +1,410 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "6a6710155f6e95378180ba5e768da071", + "entities": [ + { + "tableName": "badge_highlight", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `badgeName` TEXT NOT NULL, `isCustom` INTEGER NOT NULL, `create_notification` INTEGER NOT NULL, `custom_color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "badgeName", + "columnName": "badgeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createNotification", + "columnName": "create_notification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customColor", + "columnName": "custom_color", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "emote_usage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`emote_id` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`emote_id`))", + "fields": [ + { + "fieldPath": "emoteId", + "columnName": "emote_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "last_used", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "emote_id" + ] + } + }, + { + "tableName": "upload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `image_link` TEXT NOT NULL, `delete_link` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageLink", + "columnName": "image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleteLink", + "columnName": "delete_link", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "message_highlight", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `type` TEXT NOT NULL, `pattern` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_case_sensitive` INTEGER NOT NULL, `create_notification` INTEGER NOT NULL, `custom_color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "is_regex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCaseSensitive", + "columnName": "is_case_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createNotification", + "columnName": "create_notification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customColor", + "columnName": "custom_color", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "message_ignore", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `type` TEXT NOT NULL, `pattern` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_case_sensitive` INTEGER NOT NULL, `is_block_message` INTEGER NOT NULL, `replacement` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "is_regex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCaseSensitive", + "columnName": "is_case_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBlockMessage", + "columnName": "is_block_message", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "user_highlight", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `username` TEXT NOT NULL, `create_notification` INTEGER NOT NULL, `custom_color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createNotification", + "columnName": "create_notification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customColor", + "columnName": "custom_color", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "blacklisted_user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `username` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_case_sensitive` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "is_regex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCaseSensitive", + "columnName": "is_case_sensitive", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "blacklisted_user_highlight", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `username` TEXT NOT NULL, `is_regex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "is_regex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "user_display", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `target_user` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `color_enabled` INTEGER NOT NULL, `color` INTEGER NOT NULL, `aliasEnabled` INTEGER NOT NULL, `alias` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUser", + "columnName": "target_user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "colorEnabled", + "columnName": "color_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aliasEnabled", + "columnName": "aliasEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6a6710155f6e95378180ba5e768da071')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 2a8d83c5d..2db962def 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -37,6 +37,8 @@ import org.koin.core.context.startKoin import org.koin.ksp.generated.* class DankChatApplication : Application(), SingletonImageLoader.Factory { + // Dummy comment to force KSP re-run + private val dispatchersProvider: DispatchersProvider by inject() private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchersProvider.main) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt index 6ac5b9cc3..587eb713b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt @@ -235,7 +235,7 @@ class ChatAdapter( val firstHighlightType = message.highlights.firstOrNull()?.type val shouldHighlight = firstHighlightType == HighlightType.Subscription || firstHighlightType == HighlightType.Announcement val background = when { - shouldHighlight -> ContextCompat.getColor(context, R.color.color_sub_highlight) + shouldHighlight -> message.highlights.toBackgroundColor(context) appearanceSettings.checkeredMessages && holder.isAlternateBackground -> MaterialColors.layer( this, android.R.attr.colorBackground, @@ -350,7 +350,7 @@ class ChatAdapter( private fun TextView.handlePointRedemptionMessage(message: PointRedemptionMessage, holder: ViewHolder) { val appearanceSettings = appearanceSettingsDataStore.current() val chatSettings = chatSettingsDataStore.current() - val background = ContextCompat.getColor(context, R.color.color_redemption_highlight) + val background = message.highlights.toBackgroundColor(context) holder.binding.itemLayout.setBackgroundColor(background) setRippleBackground(background, enableRipple = false) @@ -867,12 +867,16 @@ class ChatAdapter( @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) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt index f61d1e206..5f81c80d2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt @@ -4,27 +4,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatScreen 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 @@ -33,82 +24,44 @@ 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.theme.DankChatTheme import com.flxrs.dankchat.utils.extensions.collectFlow 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() + protected 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() + protected val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() protected val chatSettingsDataStore: ChatSettingsDataStore by inject() protected val dankChatPreferenceStore: DankChatPreferenceStore by inject() - // Legacy support - will be removed 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 - private var useCompose = true // Feature flag for migration - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return if (useCompose) { - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) - val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value - val chatSettings = chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()).value - DankChatTheme { - ChatScreen( - messages = messages, - fontSize = appearanceSettings.fontSize.toFloat(), - showLineSeparator = appearanceSettings.lineSeparator, - animateGifs = chatSettings.animateGifs, - modifier = Modifier.fillMaxSize(), - onUserClick = ::onUserClickCompose, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) - }, - onEmoteClick = ::onEmoteClickCompose, - onReplyClick = ::onReplyClick - ) - } - } - } - } else { - // Legacy RecyclerView implementation - 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) - } + 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) } - binding.root } + + collectFlow(viewModel.chat) { adapter.submitList(it) } + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - if (useCompose) { - // Compose implementation doesn't need additional setup - return - } - - // Legacy setup val itemDecoration = DividerItemDecoration(view.context, LinearLayoutManager.VERTICAL) manager = LinearLayoutManager(view.context, RecyclerView.VERTICAL, false).apply { stackFromEnd = true } adapter = ChatAdapter( @@ -146,50 +99,13 @@ open class ChatFragment : Fragment() { } } - override fun onStart() { - super.onStart() - // minSdk 30+ handles GIF animations natively - } - override fun onDestroyView() { - if (!useCompose) { - binding.chat.adapter = null - binding.chat.layoutManager = null - bindingRef = null - } + binding.chat.adapter = null + binding.chat.layoutManager = null + bindingRef = null super.onDestroyView() } - override fun onStop() { - // minSdk 30+ handles animated drawables automatically - super.onStop() - } - - // Compose-specific callbacks - private fun onUserClickCompose( - targetUserId: String?, - targetUserName: String, - targetDisplayName: String, - channel: String?, - badges: List, - isLongPress: Boolean - ) { - val userId = targetUserId?.let { UserId(it) } - val userName = UserName(targetUserName) - val displayName = DisplayName(targetDisplayName) - val channelName = channel?.let { UserName(it) } - val badgeList = badges.map(BadgeUi::badge) - onUserClick(userId, userName, displayName, channelName, badgeList, isLongPress) - } - - private fun onEmoteClickCompose(emotes: List) { - (parentFragment as? MainFragment)?.openEmoteSheet(emotes) - } - - private fun onReplyClick(rootMessageId: String) { - (parentFragment as? MainFragment)?.openReplies(rootMessageId) - } - protected open fun onUserClick( targetUserId: UserId?, targetUserName: UserName, @@ -219,26 +135,24 @@ open class ChatFragment : Fragment() { (parentFragment as? MainFragment)?.openMessageSheet(messageId, channel, fullMessage, canReply = true, canModerate = true) } - // Legacy RecyclerView methods + private fun onReplyClick(rootMessageId: String) { + (parentFragment as? MainFragment)?.openReplies(rootMessageId) + } + override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) - if (!useCompose) { - savedInstanceState?.let { - isAtBottom = it.getBoolean(AT_BOTTOM_STATE) - binding.scrollBottom.isVisible = !isAtBottom - } + savedInstanceState?.let { + isAtBottom = it.getBoolean(AT_BOTTOM_STATE) + binding.scrollBottom.isVisible = !isAtBottom } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - if (!useCompose) { - outState.putBoolean(AT_BOTTOM_STATE, isAtBottom) - } + outState.putBoolean(AT_BOTTOM_STATE, isAtBottom) } protected open fun scrollToPosition(position: Int) { - if (useCompose) return bindingRef ?: return if (position > 0 && isAtBottom) { manager.scrollToPositionWithOffset(position, 0) @@ -255,10 +169,6 @@ open class ChatFragment : Fragment() { } 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt index cb145506f..69043ce39 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt @@ -1,20 +1,12 @@ package com.flxrs.dankchat.chat -import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.compose.ChatMessageMapper -import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState -import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.chat.ChatRepository -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel @@ -24,9 +16,6 @@ import kotlin.time.Duration.Companion.seconds class ChatViewModel( savedStateHandle: SavedStateHandle, repository: ChatRepository, - private val context: Context, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { private val args = ChatFragmentArgs.fromSavedStateHandle(savedStateHandle) @@ -34,28 +23,6 @@ class ChatViewModel( val chat: StateFlow> = (args.channel?.let(repository::getChat) ?: emptyFlow()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - // Compose UI states - val chatUiStates: StateFlow> = combine( - chat, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings - ) { messages, appearanceSettings, chatSettings -> - var messageCount = 0 - messages.mapIndexed { index, item -> - val isAlternateBackground = when (index) { - messages.lastIndex -> messageCount++.isEven - else -> (index - messages.size - 1).isEven - } - - item.toChatMessageUiState( - context = context, - appearanceSettings = appearanceSettings, - chatSettings = chatSettings, - isAlternateBackground = isAlternateBackground && appearanceSettings.checkeredMessages - ) - } - }.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/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 74b7b88a3..1b61d51eb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -29,7 +29,7 @@ fun ChatComposable( 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) -> Unit, + onReplyClick: (String, UserName) -> Unit, modifier: Modifier = Modifier, ) { // Create ChatComposeViewModel with channel-specific key for proper scoping diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 8aaadf7e1..2deb9c362 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -7,13 +7,16 @@ import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.extensions.isEven +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @@ -33,11 +36,12 @@ class ChatComposeViewModel( private val context: Context, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, + private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { private val chat: StateFlow> = repository .getChat(channel) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) val chatUiStates: StateFlow> = combine( chat, @@ -55,8 +59,10 @@ class ChatComposeViewModel( context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, + preferenceStore = preferenceStore, isAlternateBackground = isAlternateBackground && appearanceSettings.checkeredMessages ) } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) -} + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index b47cdeb32..faecc8eef 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -24,6 +24,7 @@ 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.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettings import com.flxrs.dankchat.preferences.chat.ChatSettings import com.flxrs.dankchat.utils.DateTimeUtils @@ -31,7 +32,7 @@ import com.google.android.material.color.MaterialColors /** * Maps domain Message objects to Compose UI state objects. - * Pre-computes all rendering decisions to minimize work during composition. + * Pre-computed all rendering decisions to minimize work during composition. */ object ChatMessageMapper { @@ -67,6 +68,7 @@ object ChatMessageMapper { context: Context, appearanceSettings: AppearanceSettings, chatSettings: ChatSettings, + preferenceStore: DankChatPreferenceStore, isAlternateBackground: Boolean, ): ChatMessageUiState { val textAlpha = when (importance) { @@ -115,6 +117,7 @@ object ChatMessageMapper { tag = this.tag, context = context, chatSettings = chatSettings, + preferenceStore = preferenceStore, isAlternateBackground = isAlternateBackground, textAlpha = textAlpha ) @@ -247,6 +250,7 @@ object ChatMessageMapper { tag: Int, context: Context, chatSettings: ChatSettings, + preferenceStore: DankChatPreferenceStore, isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.ModerationMessageUi { @@ -262,7 +266,7 @@ object ChatMessageMapper { lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, - message = "" // Moderation messages don't need text - they're action notifications + message = getSystemMessage(preferenceStore.userName, chatSettings.showTimedOutMessages) ) } @@ -522,6 +526,7 @@ object ChatMessageMapper { HighlightType.Username, HighlightType.Custom, HighlightType.Reply, + HighlightType.Badge, HighlightType.Notification -> BackgroundColors( light = COLOR_MENTION_HIGHLIGHT_LIGHT, dark = COLOR_MENTION_HIGHLIGHT_DARK, @@ -532,6 +537,12 @@ object ChatMessageMapper { private fun Set.toBackgroundColors(): BackgroundColors { val highlight = this.maxByOrNull { it.type.priority.value } ?: return BackgroundColors(Color.Transparent, Color.Transparent) + + if (highlight.customColor != null) { + val color = Color(highlight.customColor) + return BackgroundColors(color, color) + } + return getHighlightColors(highlight.type) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 33e6c480b..6c3202fa0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -32,6 +32,7 @@ import com.flxrs.dankchat.chat.compose.messages.PrivMessageComposable import com.flxrs.dankchat.chat.compose.messages.SystemMessageComposable import com.flxrs.dankchat.chat.compose.messages.UserNoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.WhisperMessageComposable +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** @@ -54,7 +55,7 @@ fun ChatScreen( showLineSeparator: Boolean = false, animateGifs: Boolean = true, onEmoteClick: (emotes: List) -> Unit = {}, - onReplyClick: (rootMessageId: String) -> Unit = {}, + onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit = { _, _ -> }, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -160,7 +161,7 @@ private fun ChatMessageItem( 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) -> Unit, + onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit, ) { when (message) { is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index ae173714a..a2a26ee5c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box 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 @@ -17,6 +18,7 @@ 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.unit.Constraints import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch @@ -62,6 +64,7 @@ fun TextWithMeasuredInlineContent( ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() + val textLayoutResultRef = remember { mutableStateOf(null) } SubcomposeLayout(modifier = modifier) { constraints -> // Phase 1: Measure all inline content to get actual dimensions @@ -100,7 +103,6 @@ fun TextWithMeasuredInlineContent( } // Phase 3: Compose the text with correct inline content - var textLayoutResult: androidx.compose.ui.text.TextLayoutResult? = null val textMeasurables = subcompose("text") { BasicText( @@ -120,37 +122,21 @@ fun TextWithMeasuredInlineContent( } }, onTap = { offset -> - textLayoutResult?.let { layoutResult -> - // Precision check: make sure the click is actually on text - val isYWithinBounds = offset.y >= 0 && offset.y <= layoutResult.size.height - if (isYWithinBounds) { - val line = layoutResult.getLineForVerticalPosition(offset.y) - val isXWithinBounds = offset.x >= layoutResult.getLineLeft(line) && offset.x <= layoutResult.getLineRight(line) - if (isXWithinBounds) { - val position = layoutResult.getOffsetForPosition(offset) - onTextClick?.invoke(position) - } - } + textLayoutResultRef.value?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + onTextClick?.invoke(position) } }, onLongPress = { offset -> - textLayoutResult?.let { layoutResult -> - // Precision check: make sure the click is actually on text - val isYWithinBounds = offset.y >= 0 && offset.y <= layoutResult.size.height - if (isYWithinBounds) { - val line = layoutResult.getLineForVerticalPosition(offset.y) - val isXWithinBounds = offset.x >= layoutResult.getLineLeft(line) && offset.x <= layoutResult.getLineRight(line) - if (isXWithinBounds) { - val position = layoutResult.getOffsetForPosition(offset) - onTextLongClick?.invoke(position) - } - } + textLayoutResultRef.value?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + onTextLongClick?.invoke(position) } } ) }, onTextLayout = { layoutResult -> - textLayoutResult = layoutResult + textLayoutResultRef.value = layoutResult } ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 5cbabb595..2a9582d42 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -38,6 +38,7 @@ import androidx.core.net.toUri import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.StackedEmote import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent @@ -45,6 +46,7 @@ import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** @@ -65,7 +67,7 @@ fun PrivMessageComposable( 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) -> Unit, + onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) @@ -83,7 +85,7 @@ fun PrivMessageComposable( Row( modifier = Modifier .fillMaxWidth() - .clickable { onReplyClick(message.thread.rootId) } + .clickable { onReplyClick(message.thread.rootId, message.thread.userName.toUserName()) } .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically ) { 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 index b955c9745..d7753b2f7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt @@ -4,65 +4,29 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.navArgs import com.flxrs.dankchat.chat.ChatFragment -import com.flxrs.dankchat.chat.compose.ChatScreen 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.main.MainFragment -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior -import com.flxrs.dankchat.theme.DankChatTheme -import org.koin.android.ext.android.inject +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() }) - private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value - val messages by when { - args.isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) - else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) - } - DankChatTheme { - ChatScreen( - messages = messages, - fontSize = appearanceSettings.fontSize.toFloat(), - showChannelPrefix = !args.isWhisperTab, // Only show for mentions, not whispers - onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> - onUserClick( - targetUserId = userId?.let { UserId(it) }, - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.filterIsInstance(), - isLongPress = isLongPress - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) - }, - onEmoteClick = { - val chatEmotes = it.filterIsInstance() - (parentFragment?.parentFragment as? MainFragment)?.openEmoteSheet(chatEmotes) - } - ) - } - } + val view = super.onCreateView(inflater, container, savedInstanceState) + val chatFlow = when { + args.isWhisperTab -> mentionViewModel.whispers + else -> mentionViewModel.mentions } + collectFlow(chatFlow) { adapter.submitList(it) } + return view } override fun onUserClick( 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 index 3cc3e1938..8f7fc6d75 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt @@ -1,66 +1,24 @@ package com.flxrs.dankchat.chat.mention -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState -import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.chat.ChatRepository -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore - import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class MentionViewModel( - chatRepository: ChatRepository, - private val context: Context, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val chatSettingsDataStore: ChatSettingsDataStore, -) : ViewModel() { +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 mentionsUiStates: StateFlow> = combine( - mentions, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings - ) { messages, appearanceSettings, chatSettings -> - messages.map { item -> - item.toChatMessageUiState( - context = context, - appearanceSettings = appearanceSettings, - chatSettings = chatSettings, - isAlternateBackground = false // No alternating in mentions - ) - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - - val whispersUiStates: StateFlow> = combine( - whispers, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings - ) { messages, appearanceSettings, chatSettings -> - messages.map { item -> - item.toChatMessageUiState( - context = context, - appearanceSettings = appearanceSettings, - chatSettings = chatSettings, - isAlternateBackground = false // No alternating in 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index 47f7d99e2..85e000cf7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen -import com.flxrs.dankchat.chat.mention.MentionViewModel import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -15,13 +14,13 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore * Extracted from MentionChatFragment to enable pure Compose integration. * * This composable: - * - Collects mentions or whispers from MentionViewModel based on isWhisperTab + * - Collects mentions or whispers from MentionComposeViewModel based on isWhisperTab * - Collects appearance settings * - Renders ChatScreen with channel prefix for mentions only */ @Composable fun MentionComposable( - mentionViewModel: MentionViewModel, + mentionViewModel: MentionComposeViewModel, appearanceSettingsDataStore: AppearanceSettingsDataStore, isWhisperTab: Boolean, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, @@ -44,4 +43,4 @@ fun MentionComposable( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt new file mode 100644 index 000000000..a344d5c7f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt @@ -0,0 +1,82 @@ +package com.flxrs.dankchat.chat.mention.compose + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import org.koin.android.annotation.KoinViewModel +import kotlin.time.Duration.Companion.seconds + +@KoinViewModel +class MentionComposeViewModel( + chatRepository: ChatRepository, + private val context: Context, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, + private val preferenceStore: DankChatPreferenceStore, +) : ViewModel() { + + private val _currentTab = MutableStateFlow(0) + val currentTab: StateFlow = _currentTab + + fun setCurrentTab(index: Int) { + _currentTab.value = index + } + + val mentions: StateFlow> = chatRepository.mentions + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), emptyList()) + val whispers: StateFlow> = chatRepository.whispers + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), emptyList()) + + val mentionsUiStates: Flow> = combine( + mentions, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, appearanceSettings, chatSettings -> + messages.map { item -> + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = false + ) + } + }.flowOn(Dispatchers.Default) + + val whispersUiStates: Flow> = combine( + whispers, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, appearanceSettings, chatSettings -> + messages.map { item -> + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = false + ) + } + }.flowOn(Dispatchers.Default) + + val hasMentions: StateFlow = chatRepository.hasMentions + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) + val hasWhispers: StateFlow = chatRepository.hasWhispers + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt index e3c582ee9..db8025839 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt @@ -8,4 +8,5 @@ data class MessageOptionsParams( val fullMessage: String, val canModerate: Boolean, val canReply: Boolean, + val canCopy: Boolean = true, ) 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 index 1994b6589..f04b108c4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt @@ -4,70 +4,27 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.ChatFragment -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatScreen 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.main.MainFragment -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior -import com.flxrs.dankchat.theme.DankChatTheme -import com.flxrs.dankchat.utils.extensions.showLongSnackbar -import org.koin.android.ext.android.inject +import com.flxrs.dankchat.utils.extensions.collectFlow import org.koin.androidx.viewmodel.ext.android.viewModel class RepliesChatFragment : ChatFragment() { private val repliesViewModel: RepliesViewModel by viewModel(ownerProducer = { requireParentFragment() }) - private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value - - @Suppress("MoveVariableDeclarationIntoWhen") - val uiState = repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())).value - - when (uiState) { - is RepliesUiState.Found -> { - DankChatTheme { - ChatScreen( - messages = uiState.items, - fontSize = appearanceSettings.fontSize.toFloat(), - onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> - onUserClick( - targetUserId = userId?.let { UserId(it) }, - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map(BadgeUi::badge), - isLongPress = isLongPress - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageClick(messageId, channel?.let { UserName(it) }, fullMessage) - }, - ) - } - } - - is RepliesUiState.NotFound -> { - // Show error - need to handle this in Compose or use side effect - androidx.compose.runtime.LaunchedEffect(Unit) { - view?.showLongSnackbar(getString(R.string.reply_thread_not_found)) - } - } - } + val view = super.onCreateView(inflater, container, savedInstanceState) + collectFlow(repliesViewModel.state) { state -> + if (state is RepliesState.Found) { + adapter.submitList(state.items) } } + return view } override fun onUserClick(targetUserId: UserId?, targetUserName: UserName, targetDisplayName: DisplayName, channel: UserName?, badges: List, isLongPress: Boolean) { 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 index c0e4bd99a..abc4f1d49 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt @@ -1,8 +1,17 @@ package com.flxrs.dankchat.chat.replies +import androidx.compose.runtime.Immutable import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +@Immutable sealed interface RepliesState { - object NotFound : RepliesState + data object NotFound : RepliesState data class Found(val items: List) : RepliesState } + +@Immutable +sealed interface RepliesUiState { + data object NotFound : RepliesUiState + data class Found(val items: List) : RepliesUiState +} \ No newline at end of file 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 index e86be8fcb..7e93cce6f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesViewModel.kt @@ -1,18 +1,10 @@ package com.flxrs.dankchat.chat.replies -import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState -import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.RepliesRepository -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel @@ -22,9 +14,6 @@ import kotlin.time.Duration.Companion.seconds class RepliesViewModel( repliesRepository: RepliesRepository, savedStateHandle: SavedStateHandle, - private val context: Context, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { private val args = RepliesFragmentArgs.fromSavedStateHandle(savedStateHandle) @@ -36,31 +25,5 @@ class RepliesViewModel( else -> RepliesState.Found(it) } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), RepliesState.Found(emptyList())) - - val uiState: StateFlow = combine( - state, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings - ) { repliesState, appearanceSettings, chatSettings -> - when (repliesState) { - is RepliesState.NotFound -> RepliesUiState.NotFound - is RepliesState.Found -> { - val uiMessages = repliesState.items.map { item -> - item.toChatMessageUiState( - context = context, - appearanceSettings = appearanceSettings, - chatSettings = chatSettings, - isAlternateBackground = false // No alternating in replies - ) - } - RepliesUiState.Found(uiMessages) - } - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), RepliesUiState.Found(emptyList())) -} - -sealed interface RepliesUiState { - data object NotFound : RepliesUiState - data class Found(val items: List) : RepliesUiState + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(emptyList())) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index 6abc0ee48..dc0f2eb69 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.replies.RepliesUiState -import com.flxrs.dankchat.chat.replies.RepliesViewModel import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore /** @@ -16,14 +15,14 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore * Extracted from RepliesChatFragment to enable pure Compose integration. * * This composable: - * - Collects reply thread state from RepliesViewModel + * - Collects reply thread state from RepliesComposeViewModel * - Collects appearance settings * - Handles NotFound state via onNotFound callback * - Renders ChatScreen for Found state */ @Composable fun RepliesComposable( - repliesViewModel: RepliesViewModel, + repliesViewModel: RepliesComposeViewModel, appearanceSettingsDataStore: AppearanceSettingsDataStore, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -50,4 +49,4 @@ fun RepliesComposable( } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt index f065f166a..9ee711b05 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt @@ -4,15 +4,17 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState -import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.replies.RepliesState import com.flxrs.dankchat.chat.replies.RepliesUiState import com.flxrs.dankchat.data.repo.RepliesRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import kotlinx.coroutines.Dispatchers 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.android.annotation.KoinViewModel @@ -26,6 +28,7 @@ class RepliesComposeViewModel( private val context: Context, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, + private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { val state = repliesRepository.getThreadItemsFlow(rootMessageId) @@ -36,7 +39,7 @@ class RepliesComposeViewModel( } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(emptyList())) - + val uiState: StateFlow = combine( state, appearanceSettingsDataStore.settings, @@ -50,11 +53,13 @@ class RepliesComposeViewModel( context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, + preferenceStore = preferenceStore, isAlternateBackground = false ) } RepliesUiState.Found(uiMessages) } } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(emptyList())) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(emptyList())) } 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 fc032487f..37d37b663 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 @@ -11,8 +11,9 @@ import com.flxrs.dankchat.data.database.dao.* import com.flxrs.dankchat.data.database.entity.* @Database( - version = 6, + version = 7, entities = [ + BadgeHighlightEntity::class, EmoteUsageEntity::class, UploadEntity::class, MessageHighlightEntity::class, @@ -27,11 +28,13 @@ import com.flxrs.dankchat.data.database.entity.* AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4), AutoMigration(from = 5, to = 6), + AutoMigration(from = 6, to = 7), ], exportSchema = true, ) @TypeConverters(InstantConverter::class) abstract class DankChatDatabase : RoomDatabase() { + abstract fun badgeHighlightDao(): BadgeHighlightDao abstract fun emoteUsageDao(): EmoteUsageDao abstract fun recentUploadsDao(): RecentUploadsDao abstract fun userDisplayDao(): UserDisplayDao 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 new file mode 100644 index 000000000..43a58472b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BadgeHighlightDao.kt @@ -0,0 +1,33 @@ +package com.flxrs.dankchat.data.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import com.flxrs.dankchat.data.database.entity.BadgeHighlightEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface BadgeHighlightDao { + + @Query("SELECT * FROM badge_highlight WHERE id = :id") + suspend fun getBadgeHighlight(id: Long): BadgeHighlightEntity + + @Query("SELECT * FROM badge_highlight") + suspend fun getBadgeHighlights(): List + + @Query("SELECT * FROM badge_highlight") + fun getBadgeHighlightsFlow(): Flow> + + @Upsert + suspend fun addHighlight(highlight: BadgeHighlightEntity): Long + + @Upsert + suspend fun addHighlights(highlights: List) + + @Delete + suspend fun deleteHighlight(highlight: BadgeHighlightEntity) + + @Query("DELETE FROM badge_highlight") + suspend fun deleteAllHighlights() +} 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 new file mode 100644 index 000000000..cfdc17292 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BadgeHighlightEntity.kt @@ -0,0 +1,20 @@ +package com.flxrs.dankchat.data.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "badge_highlight") +data class BadgeHighlightEntity( + @PrimaryKey(autoGenerate = true) + val id: Long, + 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 +) 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 bcb7a4ea9..918a2cd88 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 @@ -5,9 +5,11 @@ 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 import com.flxrs.dankchat.data.database.dao.BlacklistedUserDao import com.flxrs.dankchat.data.database.dao.MessageHighlightDao import com.flxrs.dankchat.data.database.dao.UserHighlightDao +import com.flxrs.dankchat.data.database.entity.BadgeHighlightEntity import com.flxrs.dankchat.data.database.entity.BlacklistedUserEntity import com.flxrs.dankchat.data.database.entity.MessageHighlightEntity import com.flxrs.dankchat.data.database.entity.MessageHighlightEntityType @@ -40,6 +42,7 @@ import org.koin.core.annotation.Single class HighlightsRepository( private val messageHighlightDao: MessageHighlightDao, private val userHighlightDao: UserHighlightDao, + private val badgeHighlightDao: BadgeHighlightDao, private val blacklistedUserDao: BlacklistedUserDao, private val preferences: DankChatPreferenceStore, private val notificationsSettingsDataStore: NotificationsSettingsDataStore, @@ -57,6 +60,8 @@ class HighlightsRepository( .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) val userHighlights = userHighlightDao.getUserHighlightsFlow().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 @@ -65,6 +70,9 @@ class HighlightsRepository( 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()) @@ -81,20 +89,24 @@ class HighlightsRepository( fun runMigrationsIfNeeded() = coroutineScope.launch { runCatching { - if (messageHighlightDao.getMessageHighlights().isNotEmpty()) { - return@launch + if (messageHighlightDao.getMessageHighlights().isEmpty()) { + Log.d(TAG, "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.") + } + if (badgeHighlightDao.getBadgeHighlights().isEmpty()) { + Log.d(TAG, "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.") } - - Log.d(TAG, "Running highlights migration...") - messageHighlightDao.addHighlights(DEFAULT_HIGHLIGHTS) - - val totalHighlights = DEFAULT_HIGHLIGHTS.size - Log.d(TAG, "Highlights migration completed, added $totalHighlights entries.") }.getOrElse { Log.e(TAG, "Failed to run highlights migration", it) runCatching { messageHighlightDao.deleteAllHighlights() userHighlightDao.deleteAllHighlights() + badgeHighlightDao.deleteAllHighlights() return@launch } } @@ -145,6 +157,29 @@ class HighlightsRepository( userHighlightDao.addHighlights(entities) } + suspend fun addBadgeHighlight(): BadgeHighlightEntity { + val entity = BadgeHighlightEntity( + id = 0, + enabled = true, + badgeName = "", + isCustom = true, + ) + val id = badgeHighlightDao.addHighlight(entity) + return entity.copy(id = id) + } + + suspend fun updateBadgeHighlight(entity: BadgeHighlightEntity) { + badgeHighlightDao.addHighlight(entity) + } + + suspend fun removeBadgeHighlight(entity: BadgeHighlightEntity) { + badgeHighlightDao.deleteHighlight(entity) + } + + suspend fun updateBadgeHighlights(entities: List) { + badgeHighlightDao.addHighlights(entities) + } + suspend fun addBlacklistedUser(): BlacklistedUserEntity { val entity = BlacklistedUserEntity( id = 0, @@ -171,12 +206,14 @@ class HighlightsRepository( val messageHighlights = validMessageHighlights.value val highlights = buildSet { - if (isSub && messageHighlights.areSubsEnabled) { - add(Highlight(HighlightType.Subscription)) + val subsHighlight = messageHighlights.subsHighlight + if (isSub && subsHighlight != null) { + add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) } - if (isAnnouncement && messageHighlights.areAnnouncementsEnabled) { - add(Highlight(HighlightType.Announcement)) + val announcementsHighlight = messageHighlights.announcementsHighlight + if (isAnnouncement && announcementsHighlight != null) { + add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) } } @@ -187,15 +224,11 @@ class HighlightsRepository( } private fun PointRedemptionMessage.calculateHighlightState(): PointRedemptionMessage { - val redemptionsEnabled = validMessageHighlights.value - .any { it.type == MessageHighlightEntityType.ChannelPointRedemption } - - val highlights = when { - redemptionsEnabled -> setOf(Highlight(HighlightType.ChannelPointRedemption)) - else -> emptySet() + val rewardsHighlight = validMessageHighlights.value.rewardsHighlight + if (rewardsHighlight != null) { + return copy(highlights = setOf(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor))) } - - return copy(highlights = highlights) + return copy(highlights = emptySet()) } private fun PrivMessage.calculateHighlightState(): PrivMessage { @@ -209,32 +242,38 @@ class HighlightsRepository( } val userHighlights = validUserHighlights.value + val badgeHighlights = validBadgeHighlights.value val messageHighlights = validMessageHighlights.value val highlights = buildSet { - if (isSub && messageHighlights.areSubsEnabled) { - add(Highlight(HighlightType.Subscription)) + val subsHighlight = messageHighlights.subsHighlight + if (isSub && subsHighlight != null) { + add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) } - if (isAnnouncement && messageHighlights.areAnnouncementsEnabled) { - add(Highlight(HighlightType.Announcement)) + val announcementsHighlight = messageHighlights.announcementsHighlight + if (isAnnouncement && announcementsHighlight != null) { + add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) } - if (isReward && messageHighlights.areRewardsEnabled) { - add(Highlight(HighlightType.ChannelPointRedemption)) + val rewardsHighlight = messageHighlights.rewardsHighlight + if (isReward && rewardsHighlight != null) { + add(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor)) } - if (isFirstMessage && messageHighlights.areFirstMessagesEnabled) { - add(Highlight(HighlightType.FirstMessage)) + val firstMessageHighlight = messageHighlights.firstMessageHighlight + if (isFirstMessage && firstMessageHighlight != null) { + add(Highlight(HighlightType.FirstMessage, firstMessageHighlight.customColor)) } - if (isElevatedMessage && messageHighlights.areElevatedMessagesEnabled) { - add(Highlight(HighlightType.ElevatedMessage)) + val elevatedMessageHighlight = messageHighlights.elevatedMessageHighlight + if (isElevatedMessage && elevatedMessageHighlight != null) { + add(Highlight(HighlightType.ElevatedMessage, elevatedMessageHighlight.customColor)) } if (containsCurrentUserName) { val highlight = messageHighlights.userNameHighlight if (highlight?.enabled == true) { - add(Highlight(HighlightType.Username)) + add(Highlight(HighlightType.Username, highlight.customColor)) addNotificationHighlightIfEnabled(highlight) } } @@ -242,7 +281,7 @@ class HighlightsRepository( if (containsParticipatedReply) { val highlight = messageHighlights.repliesHighlight if (highlight?.enabled == true) { - add(Highlight(HighlightType.Reply)) + add(Highlight(HighlightType.Reply, highlight.customColor)) addNotificationHighlightIfEnabled(highlight) } } @@ -253,17 +292,33 @@ class HighlightsRepository( val regex = it.regex ?: return@forEach if (message.contains(regex)) { - add(Highlight(HighlightType.Custom)) + add(Highlight(HighlightType.Custom, it.customColor)) addNotificationHighlightIfEnabled(it) } } userHighlights.forEach { if (name.matches(it.username)) { - add(Highlight(HighlightType.Custom)) + add(Highlight(HighlightType.Custom, it.customColor)) addNotificationHighlightIfEnabled(it) } } + 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) + } + } + } + } } return copy(highlights = highlights) @@ -274,20 +329,20 @@ class HighlightsRepository( else -> this } - private val List.areSubsEnabled: Boolean - get() = isMessageHighlightTypeEnabled(MessageHighlightEntityType.Subscription) + private val List.subsHighlight: MessageHighlightEntity? + get() = find { it.type == MessageHighlightEntityType.Subscription } - private val List.areAnnouncementsEnabled: Boolean - get() = isMessageHighlightTypeEnabled(MessageHighlightEntityType.Announcement) + private val List.announcementsHighlight : MessageHighlightEntity? + get() = find { it.type == MessageHighlightEntityType.Announcement } - private val List.areRewardsEnabled: Boolean - get() = isMessageHighlightTypeEnabled(MessageHighlightEntityType.ChannelPointRedemption) + private val List.rewardsHighlight: MessageHighlightEntity? + get() = find { it.type == MessageHighlightEntityType.ChannelPointRedemption } - private val List.areFirstMessagesEnabled: Boolean - get() = isMessageHighlightTypeEnabled(MessageHighlightEntityType.FirstMessage) + private val List.firstMessageHighlight: MessageHighlightEntity? + get() = find { it.type == MessageHighlightEntityType.FirstMessage } - private val List.areElevatedMessagesEnabled: Boolean - get() = isMessageHighlightTypeEnabled(MessageHighlightEntityType.ElevatedMessage) + private val List.elevatedMessageHighlight: MessageHighlightEntity? + get() = find { it.type == MessageHighlightEntityType.ElevatedMessage } private val List.repliesHighlight: MessageHighlightEntity? get() = find { it.type == MessageHighlightEntityType.Reply } @@ -295,10 +350,6 @@ class HighlightsRepository( private val List.userNameHighlight: MessageHighlightEntity? get() = find { it.type == MessageHighlightEntityType.Username } - private fun List.isMessageHighlightTypeEnabled(type: MessageHighlightEntityType): Boolean { - return any { it.type == type } - } - private fun MutableCollection.addNotificationHighlightIfEnabled(highlightEntity: MessageHighlightEntity) { if (highlightEntity.createNotification) { add(Highlight(HighlightType.Notification)) @@ -311,6 +362,12 @@ class HighlightsRepository( } } + private fun MutableCollection.addNotificationHighlightIfEnabled(highlightEntity: BadgeHighlightEntity) { + if (highlightEntity.createNotification) { + add(Highlight(HighlightType.Notification)) + } + } + private val PrivMessage.containsCurrentUserName: Boolean get() { val currentUser = currentUserAndDisplay.value?.first ?: return false @@ -351,7 +408,7 @@ class HighlightsRepository( } private fun List.addDefaultsIfNecessary(): List { - return (this + DEFAULT_HIGHLIGHTS).distinctBy { + return (this + DEFAULT_MESSAGE_HIGHLIGHTS).distinctBy { when (it.type) { MessageHighlightEntityType.Custom -> it.id else -> it.type @@ -361,7 +418,7 @@ class HighlightsRepository( companion object { private val TAG = HighlightsRepository::class.java.simpleName - private val DEFAULT_HIGHLIGHTS = listOf( + 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), @@ -370,5 +427,16 @@ class HighlightsRepository( 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), + ) } } 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 7b4f870b3..a06a26c59 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 @@ -653,7 +653,7 @@ class ChatRepository( } reward?.let { - listOf(ChatItem(PointRedemptionMessage.parsePointReward(it.timestamp, it.data))) + listOf(ChatItem(PointRedemptionMessage.parsePointReward(it.timestamp, it.data).calculateHighlightState())) }.orEmpty() } @@ -664,9 +664,9 @@ class ChatRepository( Message.parse(ircMessage, channelRepository::tryGetUserNameById) ?.applyIgnores() ?.calculateMessageThread { channel, id -> messages[channel]?.value?.find { it.message.id == id }?.message } - ?.calculateHighlightState() ?.calculateUserDisplays() ?.parseEmotesAndBadges() + ?.calculateHighlightState() ?.updateMessageInThread() }.getOrElse { Log.e(TAG, "Failed to parse message", it) @@ -827,9 +827,9 @@ class ChatRepository( Message.parse(parsedIrc, channelRepository::tryGetUserNameById) ?.applyIgnores() ?.calculateMessageThread { _, id -> items.find { it.message.id == id }?.message } - ?.calculateHighlightState() ?.calculateUserDisplays() ?.parseEmotesAndBadges() + ?.calculateHighlightState() ?.updateMessageInThread() }.getOrNull() ?: continue 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 b06b82512..b26220875 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 @@ -23,6 +23,7 @@ enum class HighlightType(val priority: HighlightPriority) { FirstMessage(HighlightPriority.MEDIUM), ElevatedMessage(HighlightPriority.MEDIUM), Username(HighlightPriority.LOW), + Badge(HighlightPriority.LOW), Custom(HighlightPriority.LOW), Reply(HighlightPriority.LOW), Notification(HighlightPriority.LOW), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt index a787fdf99..2d5e2ea53 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt @@ -5,4 +5,4 @@ import org.koin.core.annotation.Module @Module(includes = [ConnectionModule::class, DatabaseModule::class, NetworkModule::class, CoroutineModule::class]) @ComponentScan("com.flxrs.dankchat") -class DankChatModule +class DankChatModule // dummy comment to force re-ksp 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 9b3291386..230bf72b6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt @@ -3,6 +3,7 @@ package com.flxrs.dankchat.di import android.content.Context import androidx.room.Room import com.flxrs.dankchat.data.database.DankChatDatabase +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 @@ -50,6 +51,11 @@ class DatabaseModule { database: DankChatDatabase ): UserHighlightDao = database.userHighlightDao() + @Single + fun provideBadgeHighlightDao( + database: DankChatDatabase + ): BadgeHighlightDao = database.badgeHighlightDao() + @Single fun provideIgnoreUserDao( database: DankChatDatabase diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 0656f7adc..7f1fff153 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -6,12 +6,18 @@ 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 com.flxrs.dankchat.data.repo.chat.ChatLoadingFailure +import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep +import com.flxrs.dankchat.data.repo.data.DataLoadingFailure +import com.flxrs.dankchat.data.repo.data.DataLoadingStep 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.coroutines.async +import kotlinx.coroutines.awaitAll import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap @@ -116,4 +122,43 @@ class ChannelDataCoordinator( fun reloadGlobalData() { loadGlobalData() } -} + + /** + * Retry specific failed data and chat steps + */ + fun retryDataLoading(dataFailures: Set, chatFailures: Set) { + scope.launch { + _globalLoadingState.value = GlobalLoadingState.Loading + + // Collect channels that need retry + val channelsToRetry = mutableSetOf() + + val dataResults = dataFailures.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.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) + } + } + } + + chatFailures.forEach { failure -> + when (val step = failure.step) { + is ChatLoadingStep.RecentMessages -> channelsToRetry.add(step.channel) + } + } + + dataResults.awaitAll() + channelsToRetry.forEach { loadChannelData(it) } + + _globalLoadingState.value = GlobalLoadingState.Loaded + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index b327a52d4..6aff6637d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -2,12 +2,15 @@ package com.flxrs.dankchat.domain import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.UserId +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.ChatRepository import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.data.repo.data.DataLoadingStep 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.awaitAll @@ -43,17 +46,42 @@ class ChannelDataLoader( dataRepository.createFlowsIfNecessary(listOf(channel)) chatRepository.createFlowsIfNecessary(channel) - // Load in parallel and collect all failures + // Load recent message history first with priority + val messagesResult = runCatching { + chatRepository.loadRecentMessagesIfEnabled(channel) + }.fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.RecentMessages(channel, it) } + ) + + // Load other data in parallel val failures = withContext(dispatchersProvider.io) { val badgesResult = async { loadChannelBadges(channel, channelInfo.id) } val emotesResults = async { loadChannelEmotes(channel, channelInfo) } - val messagesResult = async { loadRecentMessages(channel) } - listOfNotNull( + val otherFailures = listOfNotNull( badgesResult.await(), *emotesResults.await().toTypedArray(), - messagesResult.await() ) + if (messagesResult != null) { + otherFailures + messagesResult + } else { + otherFailures + } + } + + // Report failures as system messages like legacy implementation + 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 { + chatRepository.makeAndPostSystemMessage(it, channel) + } } // Reparse emotes/badges - this updates the tag which triggers LazyColumn recomposition @@ -68,7 +96,7 @@ class ChannelDataLoader( } } - private suspend fun loadChannelBadges( + suspend fun loadChannelBadges( channel: UserName, channelId: UserId ): ChannelLoadingFailure.Badges? { @@ -80,7 +108,7 @@ class ChannelDataLoader( ) } - private suspend fun loadChannelEmotes( + suspend fun loadChannelEmotes( channel: UserName, channelInfo: Channel ): List { @@ -118,7 +146,7 @@ class ChannelDataLoader( } } - private suspend fun loadRecentMessages( + suspend fun loadRecentMessages( channel: UserName ): ChannelLoadingFailure.RecentMessages? { return runCatching { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 327400f9d..1acb16bf2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -5,7 +5,6 @@ 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.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext @@ -25,18 +24,26 @@ class GlobalDataLoader( suspend fun loadGlobalData(): Result = withContext(dispatchersProvider.io) { runCatching { awaitAll( - async { dataRepository.loadDankChatBadges() }, - async { dataRepository.loadGlobalBadges() }, - async { dataRepository.loadGlobalBTTVEmotes() }, - async { dataRepository.loadGlobalFFZEmotes() }, - async { dataRepository.loadGlobalSevenTVEmotes() }, - async { commandRepository.loadSupibotCommands() }, - async { ignoresRepository.loadUserBlocks() } + async { loadDankChatBadges() }, + async { loadGlobalBadges() }, + async { loadGlobalBTTVEmotes() }, + async { loadGlobalFFZEmotes() }, + async { loadGlobalSevenTVEmotes() }, + async { loadSupibotCommands() }, + async { loadUserBlocks() } ) Unit } } + suspend fun loadDankChatBadges() = dataRepository.loadDankChatBadges() + suspend fun loadGlobalBadges() = dataRepository.loadGlobalBadges() + suspend fun loadGlobalBTTVEmotes() = dataRepository.loadGlobalBTTVEmotes() + suspend fun loadGlobalFFZEmotes() = dataRepository.loadGlobalFFZEmotes() + suspend fun loadGlobalSevenTVEmotes() = dataRepository.loadGlobalSevenTVEmotes() + suspend fun loadSupibotCommands() = commandRepository.loadSupibotCommands() + suspend fun loadUserBlocks() = ignoresRepository.loadUserBlocks() + /** * Load user-specific global emotes (requires login) */ @@ -46,4 +53,4 @@ class GlobalDataLoader( ) { dataRepository.loadUserStateEmotes(globalEmoteSets, followerEmoteSets) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt index f058c0162..305e264b9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import com.flxrs.dankchat.R import com.flxrs.dankchat.login.LoginViewModel import org.koin.compose.viewmodel.koinViewModel @@ -42,6 +42,7 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( + navController: NavController, onLoginSuccess: () -> Unit, onCancel: () -> Unit, ) { @@ -51,10 +52,8 @@ fun LoginScreen( LaunchedEffect(Unit) { viewModel.events.collect { event -> if (event.successful) { + navController.previousBackStackEntry?.savedStateHandle?.set("login_success", true) onLoginSuccess() - } else { - // TODO: Show error? Legacy just navigates up mostly. - // onCancel() } } } @@ -130,4 +129,4 @@ fun LoginScreen( BackHandler { onCancel() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index eab437ea0..d4a1394f9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -6,7 +6,6 @@ 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 @@ -20,12 +19,13 @@ 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.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat.Type import androidx.core.view.WindowInsetsControllerCompat @@ -39,7 +39,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.findNavController -import androidx.core.net.toUri +import androidx.navigation.toRoute import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName @@ -51,6 +51,7 @@ import com.flxrs.dankchat.main.compose.MainScreen import com.flxrs.dankchat.main.compose.MainEventBus 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.chat.ChatSettingsScreen import com.flxrs.dankchat.preferences.chat.commands.CustomCommandsScreen @@ -78,6 +79,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.compose.viewmodel.koinViewModel class MainActivity : AppCompatActivity() { @@ -188,12 +190,6 @@ class MainActivity : AppCompatActivity() { onNavigateToSettings = { navController.navigate(Settings) }, - onMessageLongClick = { messageId, channel, fullMessage -> - // Handled in MainScreen with state - }, - onEmoteClick = { emotes -> - // Handled in MainScreen with state - }, onLogin = { navController.navigate(Login) }, @@ -219,7 +215,7 @@ class MainActivity : AppCompatActivity() { startActivity(it) } }, - onOpenUrl = { url -> + onOpenUrl = { url: String -> Intent(Intent.ACTION_VIEW).also { it.data = url.toUri() startActivity(it) @@ -243,12 +239,13 @@ class MainActivity : AppCompatActivity() { ) } composable( - enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) }, + enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, exitTransition = { fadeOut(animationSpec = tween(90)) }, - popEnterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) }, + popEnterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, popExitTransition = { fadeOut(animationSpec = tween(90)) } ) { LoginScreen( + navController = navController, onLoginSuccess = { navController.popBackStack() }, onCancel = { navController.popBackStack() } ) @@ -258,8 +255,7 @@ class MainActivity : AppCompatActivity() { if (initialState.destination.route?.contains("Main") == true) { slideInHorizontally(initialOffsetX = { it }) } else { - fadeIn(animationSpec = tween(220, delayMillis = 90)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + fadeIn(animationSpec = tween(220, delayMillis = 90)) } }, exitTransition = { @@ -273,8 +269,7 @@ class MainActivity : AppCompatActivity() { if (initialState.destination.route?.contains("Main") == true) { slideInHorizontally(initialOffsetX = { it }) } else { - fadeIn(animationSpec = tween(220, delayMillis = 90)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + fadeIn(animationSpec = tween(220, delayMillis = 90)) } }, popExitTransition = { @@ -297,22 +292,21 @@ class MainActivity : AppCompatActivity() { }, onNavigateRequested = { destinationId -> when (destinationId) { - R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment -> navController.navigate(AppearanceSettings) + R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment -> navController.navigate(AppearanceSettings) R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment -> navController.navigate(NotificationsSettings) - R.id.action_overviewSettingsFragment_to_chatSettingsFragment -> navController.navigate(ChatSettings) - R.id.action_overviewSettingsFragment_to_streamsSettingsFragment -> navController.navigate(StreamsSettings) - R.id.action_overviewSettingsFragment_to_toolsSettingsFragment -> navController.navigate(ToolsSettings) - R.id.action_overviewSettingsFragment_to_developerSettingsFragment -> navController.navigate(DeveloperSettings) - R.id.action_overviewSettingsFragment_to_changelogSheetFragment -> navController.navigate(ChangelogSettings) - R.id.action_overviewSettingsFragment_to_aboutFragment -> navController.navigate(AboutSettings) + R.id.action_overviewSettingsFragment_to_chatSettingsFragment -> navController.navigate(ChatSettings) + R.id.action_overviewSettingsFragment_to_streamsSettingsFragment -> navController.navigate(StreamsSettings) + R.id.action_overviewSettingsFragment_to_toolsSettingsFragment -> navController.navigate(ToolsSettings) + R.id.action_overviewSettingsFragment_to_developerSettingsFragment -> navController.navigate(DeveloperSettings) + R.id.action_overviewSettingsFragment_to_changelogSheetFragment -> navController.navigate(ChangelogSettings) + R.id.action_overviewSettingsFragment_to_aboutFragment -> navController.navigate(AboutSettings) } } ) } - + val settingsEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { - fadeIn(animationSpec = tween(220, delayMillis = 90)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + fadeIn(animationSpec = tween(220, delayMillis = 90)) } val settingsExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { fadeOut(animationSpec = tween(90)) @@ -592,4 +586,4 @@ class MainActivity : AppCompatActivity() { private val TAG = MainActivity::class.java.simpleName const val OPEN_CHANNEL_KEY = "open_channel" } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt index f94867fe3..0785fc5f8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt @@ -50,5 +50,8 @@ object ChangelogSettings @Serializable object AboutSettings +@Serializable +object EmoteMenu + @Serializable object Login diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt index e0e828eb4..90afcc00a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt @@ -10,6 +10,8 @@ import com.flxrs.dankchat.preferences.DankChatPreferenceStore 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.android.annotation.KoinViewModel @@ -20,32 +22,42 @@ class ChannelTabViewModel( private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { - val uiState: StateFlow = combine( - preferenceStore.getChannelsWithRenamesFlow(), - chatRepository.activeChannel, - chatRepository.unreadMessagesMap, - chatRepository.channelMentionCount, - ) { channels, active, unread, mentions -> - ChannelTabUiState( - tabs = channels.map { 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 = channelDataCoordinator.getChannelLoadingState( - channelWithRename.channel - ).value + 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( + chatRepository.activeChannel, + chatRepository.unreadMessagesMap, + chatRepository.channelMentionCount, + combine(loadingFlows) { it.toList() } + ) { active, unread, mentions, loadingStates -> + 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, + selectedIndex = channels + .indexOfFirst { it.channel == active } + .coerceAtLeast(0), + loading = tabs.any { it.loadingState == ChannelLoadingState.Loading }, ) - }, - selectedIndex = channels - .indexOfFirst { it.channel == active } - .coerceAtLeast(0), - loading = false, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) fun selectTab(index: Int) { val channels = preferenceStore.channels @@ -71,4 +83,4 @@ data class ChannelTabItem( val hasUnread: Boolean, val mentionCount: Int, val loadingState: ChannelLoadingState -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index a20010a42..5b93b2d7b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,22 +1,61 @@ package com.flxrs.dankchat.main.compose -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.animation.AnimatedVisibility +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.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.indication +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.Row +import androidx.compose.foundation.layout.Spacer 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.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.pointer.pointerInput import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState -import com.flxrs.dankchat.utils.compose.avoidRoundedCorners +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun ChatInputLayout( @@ -24,8 +63,14 @@ fun ChatInputLayout( inputState: InputState, enabled: Boolean, canSend: Boolean, + showReplyOverlay: Boolean, + replyName: UserName?, onSend: () -> Unit, + onLongSend: () -> Unit, + onSendHold: (Boolean) -> Unit, + isRepeatedSendEnabled: Boolean, onEmoteClick: () -> Unit, + onReplyDismiss: () -> Unit, modifier: Modifier = Modifier ) { val hint = when (inputState) { @@ -34,43 +79,209 @@ fun ChatInputLayout( InputState.NotLoggedIn -> stringResource(R.string.hint_not_logged_int) InputState.Disconnected -> stringResource(R.string.hint_disconnected) } - - // Input field with TextFieldState - OutlinedTextField( - enabled = enabled, - shape = MaterialTheme.shapes.extraLarge, - state = textFieldState, - leadingIcon = { - IconButton( - onClick = onEmoteClick, - enabled = enabled + + Surface( + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + ) { + // Reply Header + AnimatedVisibility( + visible = showReplyOverlay && replyName != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() ) { - Icon( - imageVector = Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.emote_menu_hint) - ) + 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 = stringResource(R.string.reply_header, replyName?.value.orEmpty()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + IconButton( + onClick = onReplyDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + modifier = Modifier.size(16.dp) + ) + } + } + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } } - }, - trailingIcon = { - IconButton( - onClick = onSend, - enabled = canSend + + // Text Field + TextField( + state = textFieldState, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 0.dp), // Reduce bottom padding as actions are below + label = { Text(hint) }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + shape = RoundedCornerShape(0.dp), + lineLimits = TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = 5 + ) + ) + + // Actions Row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.send_hint) + // Emote Button (Left) + IconButton( + onClick = onEmoteClick, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = stringResource(R.string.emote_menu_hint), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // History Button (Only when empty) + AnimatedVisibility( + visible = textFieldState.text.isEmpty(), + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + IconButton( + onClick = onLongSend, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.resume_scroll), // Using resume_scroll as a placeholder for "History/Last message" context + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Spam Button (Only when not empty and enabled) + AnimatedVisibility( + visible = textFieldState.text.isNotEmpty() && isRepeatedSendEnabled, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + SpamButton( + enabled = enabled, + onSendHold = onSendHold + ) + } + + // Send Button (Right) + SendButton( + enabled = canSend, + onSend = onSend, + modifier = Modifier ) } - }, + } + } +} + +@Composable +private fun SpamButton( + enabled: Boolean, + onSendHold: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + val interactionSource = remember { MutableInteractionSource() } + + Box( + contentAlignment = Alignment.Center, modifier = modifier - .fillMaxWidth() - .avoidRoundedCorners(fallback = PaddingValues()), - label = { - Text(hint) - }, - lineLimits = androidx.compose.foundation.text.input.TextFieldLineLimits.MultiLine( - minHeightInLines = 1, - maxHeightInLines = 5 + .size(40.dp) + .clip(CircleShape) + .indication( + interactionSource = interactionSource, + indication = ripple() + ) + .pointerInput(enabled) { + if (!enabled) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown() + var isHolding = false + val timerJob = scope.launch { + delay(500L) + isHolding = true + onSendHold(true) + } + + val up = waitForUpOrCancellation() + timerJob.cancel() + + if (up != null || isHolding) { + onSendHold(false) + } + } + } + ) { + Icon( + imageVector = Icons.Default.Repeat, + contentDescription = null, // TODO: Add string for "Spam" + tint = MaterialTheme.colorScheme.onSurfaceVariant ) - ) -} \ No newline at end of file + } +} + +@Composable +private fun SendButton( + enabled: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + val contentColor = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + } + + IconButton( + onClick = onSend, + enabled = enabled, + modifier = modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.send_hint), + tint = contentColor + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index e3f94c95c..680bfa7bb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -9,19 +9,33 @@ import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.chat.suggestion.SuggestionProvider import com.flxrs.dankchat.data.UserName +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.command.TwitchCommand import com.flxrs.dankchat.main.InputState +import com.flxrs.dankchat.main.MainEvent +import com.flxrs.dankchat.main.RepeatedSendData +import com.flxrs.dankchat.main.compose.FullScreenSheetState import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @@ -29,8 +43,13 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class ChatInputViewModel( private val chatRepository: ChatRepository, + private val commandRepository: CommandRepository, + private val channelRepository: ChannelRepository, + private val userStateRepository: UserStateRepository, private val suggestionProvider: SuggestionProvider, private val preferenceStore: DankChatPreferenceStore, + private val developerSettingsDataStore: DeveloperSettingsDataStore, + private val mainEventBus: MainEventBus, ) : ViewModel() { val textFieldState = TextFieldState() @@ -38,6 +57,12 @@ class ChatInputViewModel( private val _isReplying = MutableStateFlow(false) val isReplying: StateFlow = _isReplying + private val _replyMessageId = MutableStateFlow(null) + private val _replyName = MutableStateFlow(null) + private val repeatedSend = MutableStateFlow(RepeatedSendData(enabled = false, message = "")) + private val fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) + private val mentionSheetTab = MutableStateFlow(0) + // Create flow from TextFieldState private val textFlow = snapshotFlow { textFieldState.text.toString() } @@ -54,53 +79,195 @@ class ChatInputViewModel( suggestionProvider.getSuggestions(text, channel) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val uiState: StateFlow = combine( - textFlow, - suggestions, - chatRepository.activeChannel, - chatRepository.activeChannel.flatMapLatest { channel -> - if (channel == null) flowOf(ConnectionState.DISCONNECTED) - else chatRepository.getConnectionState(channel) - }, - combine(preferenceStore.isLoggedInFlow, isReplying) { loggedIn, replying -> loggedIn to replying } - ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, isReplying) -> - val inputState = when (connectionState) { - ConnectionState.CONNECTED -> when { - isReplying -> InputState.Replying - else -> InputState.Default + private var _uiState: StateFlow? = null + + init { + viewModelScope.launch { + chatRepository.activeChannel.collect { + repeatedSend.update { it.copy(enabled = false) } + } + } + + viewModelScope.launch { + repeatedSend.collectLatest { + if (it.enabled && it.message.isNotBlank()) { + while (isActive) { + val activeChannel = chatRepository.activeChannel.value ?: break + val delay = userStateRepository.getSendDelay(activeChannel) + trySendMessageOrCommand(it.message, skipSuspendingCommands = true) + delay(delay) + } + } } - ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn - ConnectionState.DISCONNECTED -> InputState.Disconnected + } + } + + private data class UiDependencies( + val text: String, + val suggestions: List, + val activeChannel: UserName?, + val connectionState: ConnectionState, + val isLoggedIn: Boolean + ) + + private data class SheetAndReplyState( + val sheetState: FullScreenSheetState, + val tab: Int, + val isReplying: Boolean, + val replyName: UserName?, + val replyMessageId: String? + ) + + fun uiState(fullScreenSheetState: StateFlow, mentionSheetTab: StateFlow): StateFlow { + if (_uiState != null) return _uiState!! + + val baseFlow = combine( + textFlow, + suggestions, + chatRepository.activeChannel, + chatRepository.activeChannel.flatMapLatest { channel -> + if (channel == null) flowOf(ConnectionState.DISCONNECTED) + else chatRepository.getConnectionState(channel) + }, + preferenceStore.isLoggedInFlow + ) { text, suggestions, activeChannel, connectionState, isLoggedIn -> + UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn) } - val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn - val enabled = isLoggedIn && connectionState == ConnectionState.CONNECTED - - ChatInputUiState( - text = text, - canSend = canSend, - enabled = enabled, - suggestions = suggestions, - activeChannel = activeChannel, - connectionState = connectionState, - isLoggedIn = isLoggedIn, - inputState = inputState - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) + val sheetAndReplyFlow = combine( + fullScreenSheetState, + mentionSheetTab, + _isReplying, + _replyName, + _replyMessageId + ) { sheetState, tab, isReplying, replyName, replyMessageId -> + SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId) + } + + _uiState = combine( + baseFlow, + sheetAndReplyFlow + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId) -> + this.fullScreenSheetState.value = sheetState + this.mentionSheetTab.value = tab + + val isMentionsTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 0 + val isInReplyThread = sheetState is FullScreenSheetState.Replies + val effectiveIsReplying = isReplying || isInReplyThread + + val inputState = when (connectionState) { + ConnectionState.CONNECTED -> when { + effectiveIsReplying -> InputState.Replying + else -> InputState.Default + } + ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn + ConnectionState.DISCONNECTED -> InputState.Disconnected + } + + val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn && !isMentionsTabActive + val enabled = isLoggedIn && connectionState == ConnectionState.CONNECTED && !isMentionsTabActive + + val showReplyOverlay = isReplying && !isInReplyThread + val effectiveReplyName = replyName ?: (sheetState as? FullScreenSheetState.Replies)?.replyName + + ChatInputUiState( + text = text, + canSend = canSend, + enabled = enabled, + suggestions = suggestions, + activeChannel = activeChannel, + connectionState = connectionState, + isLoggedIn = isLoggedIn, + inputState = inputState, + showReplyOverlay = showReplyOverlay, + replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, + replyName = effectiveReplyName + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) + + return _uiState!! + } fun sendMessage() { val text = textFieldState.text.toString() - val channel = uiState.value.activeChannel - if (text.isNotBlank() && channel != null) { - viewModelScope.launch { - chatRepository.sendMessage(channel.value, text) - textFieldState.clearText() + if (text.isNotBlank()) { + trySendMessageOrCommand(text) + textFieldState.clearText() + } + } + + fun trySendMessageOrCommand(message: String, skipSuspendingCommands: Boolean = false) = viewModelScope.launch { + val channel = chatRepository.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, + 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 getLastMessage() { + if (textFieldState.text.isNotBlank()) { + return + } + + val lastMessage = chatRepository.getLastMessage() ?: return + textFieldState.edit { + replace(0, length, lastMessage) + placeCursorAtEnd() + } + } + + fun setRepeatedSend(enabled: Boolean) { + val message = textFieldState.text.toString() + repeatedSend.update { + RepeatedSendData(enabled, message) } } - fun setReplying(replying: Boolean) { - _isReplying.value = replying + fun setReplying(replying: Boolean, replyMessageId: String? = null, replyName: UserName? = null) { + _isReplying.value = replying || replyMessageId != null + _replyMessageId.value = replyMessageId + _replyName.value = replyName } fun insertText(text: String) { @@ -158,5 +325,8 @@ data class ChatInputUiState( val activeChannel: UserName? = null, val connectionState: ConnectionState = ConnectionState.DISCONNECTED, val isLoggedIn: Boolean = false, - val inputState: InputState = InputState.Disconnected -) + val inputState: InputState = InputState.Disconnected, + val showReplyOverlay: Boolean = false, + val replyMessageId: String? = null, + val replyName: UserName? = null, +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt new file mode 100644 index 000000000..942d679de --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt @@ -0,0 +1,80 @@ +package com.flxrs.dankchat.main.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.emotemenu.EmoteItem +import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab +import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTabItem +import com.flxrs.dankchat.data.repo.chat.ChatRepository +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.utils.extensions.flatMapLatestOrDefault +import com.flxrs.dankchat.utils.extensions.moveToFront +import com.flxrs.dankchat.utils.extensions.toEmoteItems +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.koin.android.annotation.KoinViewModel +import kotlin.time.Duration.Companion.seconds + +@KoinViewModel +class EmoteMenuViewModel( + private val chatRepository: ChatRepository, + private val dataRepository: DataRepository, + private val emoteUsageRepository: EmoteUsageRepository, +) : ViewModel() { + + private val activeChannel = chatRepository.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(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] ?: emptyList()).moveToFront(channel).toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.CHANNEL, (groupedByType[EmoteMenuTab.CHANNEL] ?: emptyList()).toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, (groupedByType[EmoteMenuTab.GLOBAL] ?: emptyList()).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/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index b9cae7c36..aad312eb2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -46,6 +46,7 @@ fun MainAppBar( totalMentionCount: Int, onAddChannel: () -> Unit, onOpenMentions: () -> Unit, + onOpenWhispers: () -> Unit, onLogin: () -> Unit, onRelogin: () -> Unit, onLogout: () -> Unit, @@ -57,17 +58,13 @@ fun MainAppBar( onReloadEmotes: () -> Unit, onReconnect: () -> Unit, onClearChat: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, onOpenSettings: () -> Unit, modifier: Modifier = Modifier ) { var currentMenu by remember { mutableStateOf(null) } - TopAppBar( - title = { Text("DankChat") }, - actions = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, actions = { // Add channel button (always visible) IconButton(onClick = onAddChannel) { Icon( @@ -127,6 +124,13 @@ fun MainAppBar( text = { Text(stringResource(R.string.account)) }, onClick = { currentMenu = AppBarMenu.Account } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.whispers)) }, + onClick = { + onOpenWhispers() + currentMenu = null + } + ) } DropdownMenuItem( @@ -215,27 +219,6 @@ fun MainAppBar( AppBarMenu.Upload -> { SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.take_picture)) }, - onClick = { - onCaptureImage() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.record_video)) }, - onClick = { - onCaptureVideo() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.choose_media)) }, - onClick = { - onChooseMedia() - currentMenu = null - } - ) } AppBarMenu.More -> { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 677308133..f6ae05dcb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -1,21 +1,41 @@ package com.flxrs.dankchat.main.compose +import android.content.ClipData +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.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imeAnimationTarget import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -23,46 +43,63 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +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.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatComposable -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.preferences.components.DankBackground -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel -import org.koin.compose.koinInject - -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.AlertDialog -import androidx.compose.ui.res.stringResource -import com.flxrs.dankchat.R -import com.flxrs.dankchat.main.MainEvent -import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog -import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog -import com.flxrs.dankchat.chat.user.compose.UserPopupDialog +import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.message.compose.MessageOptionsState import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel import com.flxrs.dankchat.chat.user.UserPopupStateParams +import com.flxrs.dankchat.chat.user.compose.UserPopupDialog +import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.DisplayName - -import androidx.compose.ui.platform.LocalUriHandler -import androidx.navigation.NavController -import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.main.compose.FullScreenSheetState +import com.flxrs.dankchat.main.compose.InputSheetState +import com.flxrs.dankchat.main.MainEvent +import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog -import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsState -import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel -import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog +import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.main.compose.sheets.EmoteMenuSheet import com.flxrs.dankchat.main.compose.sheets.MentionSheet +import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import com.flxrs.dankchat.preferences.components.DankBackground +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -70,8 +107,6 @@ fun MainScreen( navController: NavController, isLoggedIn: Boolean, onNavigateToSettings: () -> Unit, - onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, - onEmoteClick: (List) -> Unit, onLogin: () -> Unit, onRelogin: () -> Unit, onLogout: () -> Unit, @@ -85,6 +120,8 @@ fun MainScreen( onChooseMedia: () -> Unit, modifier: Modifier = Modifier ) { + val context = LocalContext.current + val clipboardManager = LocalClipboard.current // Scoped ViewModels - each handles one concern val mainScreenViewModel: MainScreenViewModel = koinViewModel() val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() @@ -92,11 +129,18 @@ fun MainScreen( val channelPagerViewModel: ChannelPagerViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val mentionViewModel: com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel = koinViewModel() val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() + val developerSettingsDataStore: DeveloperSettingsDataStore = koinInject() + val preferenceStore: DankChatPreferenceStore = koinInject() val mainEventBus: MainEventBus = koinInject() + val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current + val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = developerSettingsDataStore.current()) + val isRepeatedSendEnabled = developerSettings.repeatedSending + var showAddChannelDialog by remember { mutableStateOf(false) } var showManageChannelsDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) } @@ -108,101 +152,48 @@ fun MainScreen( var emoteInfoEmotes by remember { mutableStateOf?>(null) } val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() - - LaunchedEffect(fullScreenSheetState) { - chatInputViewModel.setReplying(fullScreenSheetState is FullScreenSheetState.Replies) - } + val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { mainEventBus.events.collect { event -> - if (event is MainEvent.LogOutRequested) { - showLogoutDialog = true + when (event) { + is MainEvent.LogOutRequested -> showLogoutDialog = true + else -> Unit } } } - when (val state = fullScreenSheetState) { - is FullScreenSheetState.Closed -> Unit - is FullScreenSheetState.Mention -> { - MentionSheet( - initialisWhisperTab = false, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - } - ) - } - is FullScreenSheetState.Whisper -> { - MentionSheet( - initialisWhisperTab = true, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes + // Handle Login Result (previously in handleLoginRequest) + val navBackStackEntry = navController.currentBackStackEntry + val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } + LaunchedEffect(loginSuccess) { + if (loginSuccess == true) { + channelManagementViewModel.reconnect() + mainScreenViewModel.reloadGlobalData() + navBackStackEntry?.savedStateHandle?.remove("login_success") + scope.launch { + val name = preferenceStore.userName + val message = if (name != null) { + context.getString(R.string.snackbar_login, name) + } else { + context.getString(R.string.login) // Fallback } - ) + snackbarHostState.showSnackbar(message) + } } - is FullScreenSheetState.Replies -> { - RepliesSheet( - rootMessageId = state.replyMessageId, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn - ) - } - ) + } + + // Handle data loading errors + val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() + LaunchedEffect(loadingState) { + if (loadingState is GlobalLoadingState.Failed) { + val state = loadingState as GlobalLoadingState.Failed + scope.launch { + snackbarHostState.showSnackbar( + message = state.message, + actionLabel = context.getString(R.string.snackbar_retry) + ) + } } } @@ -212,7 +203,7 @@ fun MainScreen( if (showAddChannelDialog) { AddChannelDialog( onDismiss = { showAddChannelDialog = false }, - onAddChannel = { + onAddChannel = { channelManagementViewModel.addChannel(it) showAddChannelDialog = false } @@ -335,15 +326,23 @@ fun MainScreen( fullMessage = params.fullMessage, canModerate = s.canModerate, canReply = s.canReply, + canCopy = params.canCopy, hasReplyThread = s.hasReplyThread, - onReply = { - sheetNavigationViewModel.openReplies(s.messageId) + onReply = { + chatInputViewModel.setReplying(true, s.messageId, s.replyName) }, - onViewThread = { - sheetNavigationViewModel.openReplies(s.rootThreadId) + onViewThread = { + sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) + }, + onCopy = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onMoreActions = { + sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) }, - onCopy = { /* TODO: Implement copy to clipboard */ }, - onMoreActions = { /* TODO: Implement more actions */ }, onDelete = viewModel::deleteMessage, onTimeout = viewModel::timeoutUser, onBan = viewModel::banUser, @@ -379,38 +378,76 @@ fun MainScreen( onBlockUser = viewModel::blockUser, onUnblockUser = viewModel::unblockUser, onDismiss = { userPopupParams = null }, - onMention = { name, _ -> - chatInputViewModel.insertText("@$name ") + onMention = { name, _ -> + chatInputViewModel.insertText("@$name ") }, onWhisper = { name -> - sheetNavigationViewModel.openWhispers() chatInputViewModel.updateInputText("/w $name ") }, onOpenChannel = { _ -> onOpenChannel() }, onReport = { _ -> - onReportChannel() + onReportChannel() } ) } + if (inputSheetState is InputSheetState.EmoteMenu) { + EmoteMenuSheet( + onDismiss = sheetNavigationViewModel::closeInputSheet, + onEmoteClick = { code, _ -> + chatInputViewModel.insertText("$code ") + sheetNavigationViewModel.closeInputSheet() + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) + } + + if (inputSheetState is InputSheetState.MoreActions) { + val state = inputSheetState as InputSheetState.MoreActions + com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( + messageId = state.messageId, + fullMessage = state.fullMessage, + onCopyFullMessage = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onCopyMessageId = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_id_copied)) + } + }, + onDismiss = sheetNavigationViewModel::closeInputSheet, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) + } + + val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() + val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() + val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() + + val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route + val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() - val inputState by chatInputViewModel.uiState.collectAsStateWithLifecycle() + val inputState by chatInputViewModel.uiState(sheetNavigationViewModel.fullScreenSheetState, mentionViewModel.currentTab).collectAsStateWithLifecycle() val composePagerState = rememberPagerState( initialPage = pagerState.currentPage, pageCount = { pagerState.channels.size } ) val density = LocalDensity.current - var inputHeight by remember { mutableStateOf(0.dp) } + var inputHeightPx by remember { mutableIntStateOf(0) } + val inputHeightDp = with(density) { inputHeightPx.toDp() } - // Track keyboard visibility - hide immediately when closing animation starts + // Track keyboard visibility - clear focus only when keyboard is fully closed val focusManager = LocalFocusManager.current val imeAnimationTarget = WindowInsets.imeAnimationTarget - val isKeyboardVisible = WindowInsets.isImeVisible && - imeAnimationTarget.getBottom(density) > 0 + val isKeyboardAtBottom = imeAnimationTarget.getBottom(density) == 0 - LaunchedEffect(isKeyboardVisible) { - if (!isKeyboardVisible) { + LaunchedEffect(isKeyboardAtBottom) { + if (isKeyboardAtBottom) { focusManager.clearFocus() } } @@ -431,57 +468,104 @@ fun MainScreen( } } - Scaffold( + val isKeyboardVisible = WindowInsets.isImeVisible || imeAnimationTarget.getBottom(density) > 0 + + val systemBarsPaddingModifier = if (isFullscreen) Modifier else Modifier.statusBarsPadding() + + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + modifier = modifier + .fillMaxSize() + .then(systemBarsPaddingModifier) + .imePadding(), topBar = { if (tabState.tabs.isEmpty()) { return@Scaffold } - MainAppBar( - isLoggedIn = isLoggedIn, - totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onAddChannel = { showAddChannelDialog = true }, - onOpenMentions = { sheetNavigationViewModel.openMentions() }, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = { showLogoutDialog = true }, - onManageChannels = { showManageChannelsDialog = true }, - onOpenChannel = onOpenChannel, - onRemoveChannel = { showRemoveChannelDialog = true }, - onReportChannel = onReportChannel, - onBlockChannel = { showBlockChannelDialog = true }, - onReloadEmotes = { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() - }, - onReconnect = { - channelManagementViewModel.reconnect() - onReconnect() - }, - onClearChat = { showClearChatDialog = true }, - onCaptureImage = onCaptureImage, - onCaptureVideo = onCaptureVideo, - onChooseMedia = onChooseMedia, - onOpenSettings = onNavigateToSettings - ) + AnimatedVisibility( + visible = showAppBar && !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + MainAppBar( + isLoggedIn = isLoggedIn, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, + onAddChannel = { showAddChannelDialog = true }, + onOpenMentions = { sheetNavigationViewModel.openMentions() }, + onOpenWhispers = { sheetNavigationViewModel.openWhispers() }, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = { showLogoutDialog = true }, + onManageChannels = { showManageChannelsDialog = true }, + onOpenChannel = onOpenChannel, + onRemoveChannel = { showRemoveChannelDialog = true }, + onReportChannel = onReportChannel, + onBlockChannel = { showBlockChannelDialog = true }, + onReloadEmotes = { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + onReloadEmotes() + }, + onReconnect = { + channelManagementViewModel.reconnect() + onReconnect() + }, + onClearChat = { showClearChatDialog = true }, + onOpenSettings = onNavigateToSettings + ) + } }, - modifier = modifier - .fillMaxSize() - .imePadding(), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + AnimatedVisibility( + visible = showInputState && !isFullscreen, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + Box(modifier = Modifier.fillMaxWidth()) { + ChatInputLayout( + textFieldState = chatInputViewModel.textFieldState, + inputState = inputState.inputState, + enabled = inputState.enabled, + canSend = inputState.canSend, + showReplyOverlay = inputState.showReplyOverlay, + replyName = inputState.replyName, + onSend = chatInputViewModel::sendMessage, + onLongSend = chatInputViewModel::getLastMessage, + onSendHold = chatInputViewModel::setRepeatedSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, + onReplyDismiss = { + chatInputViewModel.setReplying(false) + }, + modifier = Modifier.onGloballyPositioned { coordinates -> + inputHeightPx = coordinates.size.height + } + ) + } + } + } ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize()) { - DankBackground(visible = tabState.loading) - if (tabState.loading) { + // Main content of the chat (tabs, pager, empty state) + Box(modifier = Modifier.fillMaxSize()) { // This box gets the Scaffold's content padding + val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() + DankBackground(visible = showFullScreenLoading) + if (showFullScreenLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + ) return@Scaffold } - if (tabState.tabs.isEmpty()) { + if (tabState.tabs.isEmpty() && !tabState.loading) { EmptyStateContent( isLoggedIn = isLoggedIn, onAddChannel = { showAddChannelDialog = true }, onLogin = onLogin, - onToggleAppBar = { /* TODO */ }, - onToggleFullscreen = { /* TODO */ }, - onToggleInput = { /* TODO */ }, + onToggleAppBar = mainScreenViewModel::toggleAppBar, + onToggleFullscreen = mainScreenViewModel::toggleFullscreen, + onToggleInput = mainScreenViewModel::toggleInput, modifier = Modifier.padding(paddingValues) ) } else { @@ -490,22 +574,28 @@ fun MainScreen( .fillMaxSize() .padding(paddingValues) ) { - // Tabs - Single state from ChannelTabViewModel - ChannelTabRow( - tabs = tabState.tabs, - selectedIndex = tabState.selectedIndex, - onTabSelected = { - channelTabViewModel.selectTab(it) - scope.launch { - composePagerState.animateScrollToPage(it) + if (tabState.loading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + AnimatedVisibility( + visible = !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + ChannelTabRow( + tabs = tabState.tabs, + selectedIndex = tabState.selectedIndex, + onTabSelected = { + channelTabViewModel.selectTab(it) + scope.launch { + composePagerState.animateScrollToPage(it) + } } - } - ) - - // Chat pager - State from ChannelPagerViewModel + ) + } HorizontalPager( state = composePagerState, - modifier = Modifier.weight(1f) + modifier = Modifier.fillMaxSize() ) { page -> if (page in pagerState.channels.indices) { val channel = pagerState.channels[page] @@ -526,45 +616,147 @@ fun MainScreen( channel = channel?.let { UserName(it) }, fullMessage = fullMessage, canModerate = isLoggedIn, - canReply = isLoggedIn + canReply = isLoggedIn, + canCopy = true ) }, onEmoteClick = { emotes -> emoteInfoEmotes = emotes }, - onReplyClick = { replyMessageId -> - sheetNavigationViewModel.openReplies(replyMessageId) + onReplyClick = { replyMessageId, replyName -> + sheetNavigationViewModel.openReplies(replyMessageId, replyName) } ) } } + } + } + } + } - // Input - State from ChatInputViewModel - ChatInputLayout( - textFieldState = chatInputViewModel.textFieldState, - inputState = inputState.inputState, - enabled = inputState.enabled, - canSend = inputState.canSend, - onSend = chatInputViewModel::sendMessage, - onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, - modifier = Modifier.onGloballyPositioned { coordinates -> - inputHeight = with(density) { coordinates.size.height.toDp() } + // Fullscreen Overlay Sheets + androidx.compose.animation.AnimatedVisibility( + visible = fullScreenSheetState !is FullScreenSheetState.Closed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier + .fillMaxSize() + .imePadding() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + when (val state = fullScreenSheetState) { + is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Mention -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = false, + appearanceSettingsDataStore = appearanceSettingsDataStore, + inputHeight = inputHeightDp, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + } + ) + } + is FullScreenSheetState.Whisper -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = true, + appearanceSettingsDataStore = appearanceSettingsDataStore, + inputHeight = inputHeightDp, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes } ) } - } - // Suggestion dropdown floats above input field - if (isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(paddingValues) - .padding(bottom = inputHeight) - ) + is FullScreenSheetState.Replies -> { + RepliesSheet( + rootMessageId = state.replyMessageId, + appearanceSettingsDataStore = appearanceSettingsDataStore, + inputHeight = inputHeightDp, + onDismiss = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + } + ) + } } } } + + if (showInputState && !isFullscreen && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp) + ) + } +} } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index 52f473285..0feb55fa1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -1,9 +1,18 @@ package com.flxrs.dankchat.main.compose import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel /** @@ -20,12 +29,23 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class MainScreenViewModel( private val channelDataCoordinator: ChannelDataCoordinator, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, ) : ViewModel() { // Only expose truly global state val globalLoadingState: StateFlow = channelDataCoordinator.globalLoadingState + val showInput: StateFlow = appearanceSettingsDataStore.settings + .map { it.showInput } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + private val _isFullscreen = MutableStateFlow(false) + val isFullscreen: StateFlow = _isFullscreen.asStateFlow() + + private val _showAppBar = MutableStateFlow(true) + val showAppBar: StateFlow = _showAppBar.asStateFlow() + init { // Load global data once at startup channelDataCoordinator.loadGlobalData() @@ -34,4 +54,22 @@ class MainScreenViewModel( fun reloadGlobalData() { channelDataCoordinator.reloadGlobalData() } + + fun toggleInput() { + viewModelScope.launch { + appearanceSettingsDataStore.update { it.copy(showInput = !it.showInput) } + } + } + + fun toggleFullscreen() { + _isFullscreen.update { !it } + } + + fun toggleAppBar() { + _showAppBar.update { !it } + } + + fun retryDataLoading(dataFailures: Set, chatFailures: Set) { + channelDataCoordinator.retryDataLoading(dataFailures, chatFailures) + } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt index 952e6af94..cb16a59ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.main.compose import androidx.lifecycle.ViewModel +import com.flxrs.dankchat.data.UserName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,8 +16,8 @@ class SheetNavigationViewModel : ViewModel() { private val _inputSheetState = MutableStateFlow(InputSheetState.Closed) val inputSheetState: StateFlow = _inputSheetState.asStateFlow() - fun openReplies(rootMessageId: String) { - _fullScreenSheetState.value = FullScreenSheetState.Replies(rootMessageId) + fun openReplies(rootMessageId: String, replyName: UserName) { + _fullScreenSheetState.value = FullScreenSheetState.Replies(rootMessageId, replyName) } fun openMentions() { @@ -35,6 +36,10 @@ class SheetNavigationViewModel : ViewModel() { _inputSheetState.value = InputSheetState.EmoteMenu } + fun openMoreActions(messageId: String, fullMessage: String) { + _inputSheetState.value = InputSheetState.MoreActions(messageId, fullMessage) + } + fun closeInputSheet() { _inputSheetState.value = InputSheetState.Closed } @@ -56,7 +61,7 @@ class SheetNavigationViewModel : ViewModel() { sealed interface FullScreenSheetState { data object Closed : FullScreenSheetState - data class Replies(val replyMessageId: String) : FullScreenSheetState + data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState data object Mention : FullScreenSheetState data object Whisper : FullScreenSheetState } @@ -64,4 +69,5 @@ sealed interface FullScreenSheetState { sealed interface InputSheetState { data object Closed : InputSheetState data object EmoteMenu : InputSheetState + data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt index a0c8fec55..d07914f67 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt @@ -21,6 +21,7 @@ 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.Slider @@ -34,6 +35,7 @@ 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.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -47,6 +49,7 @@ fun MessageOptionsDialog( fullMessage: String, canModerate: Boolean, canReply: Boolean, + canCopy: Boolean, hasReplyThread: Boolean, onReply: () -> Unit, onViewThread: () -> Unit, @@ -89,23 +92,28 @@ fun MessageOptionsDialog( ) } - MessageOptionItem( - icon = Icons.Default.ContentCopy, - text = stringResource(R.string.message_copy), - onClick = { - onCopy() - onDismiss() - } - ) - - MessageOptionItem( - icon = Icons.Default.MoreVert, - text = stringResource(R.string.message_more_actions), - onClick = { - onMoreActions() - onDismiss() - } - ) + if (canCopy) { + MessageOptionItem( + icon = Icons.Default.ContentCopy, + text = stringResource(R.string.message_copy), + onClick = { + onCopy() + onDismiss() + } + ) + + MessageOptionItem( + icon = Icons.Default.MoreVert, + text = stringResource(R.string.message_more_actions), + onClick = { + onMoreActions() + // Don't call onDismiss() here if the state management in MainScreen + // handles switching sheets, but the user said "it closes the current sheet" + // If we are using ModalBottomSheet, opening another one usually dismisses the first. + onDismiss() + } + ) + } if (canModerate) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) @@ -205,7 +213,8 @@ private fun MessageOptionItem( ListItem( headlineContent = { Text(text) }, leadingContent = { Icon(icon, contentDescription = null) }, - modifier = Modifier.clickable(onClick = onClick) + modifier = Modifier.clickable(onClick = onClick), + colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MoreActionsSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MoreActionsSheet.kt new file mode 100644 index 000000000..6f002f07d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MoreActionsSheet.kt @@ -0,0 +1,62 @@ +package com.flxrs.dankchat.main.compose.dialogs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoreActionsSheet( + messageId: String, + fullMessage: String, + onCopyFullMessage: (String) -> Unit, + onCopyMessageId: (String) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + ListItem( + headlineContent = { Text(stringResource(R.string.message_copy_full)) }, + leadingContent = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, + modifier = Modifier.clickable { + onCopyFullMessage(fullMessage) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + ListItem( + headlineContent = { Text(stringResource(R.string.message_copy_id)) }, + leadingContent = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, + modifier = Modifier.clickable { + onCopyMessageId(messageId) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt new file mode 100644 index 000000000..e4a557843 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt @@ -0,0 +1,141 @@ +package com.flxrs.dankchat.main.compose.sheets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.SheetState +import androidx.compose.material3.Tab +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.emotemenu.EmoteItem +import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab +import com.flxrs.dankchat.main.compose.EmoteMenuViewModel +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmoteMenuSheet( + onDismiss: () -> Unit, + onEmoteClick: (String, String) -> Unit, + sheetState: SheetState, + viewModel: EmoteMenuViewModel = koinViewModel(), +) { + val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { tabItems.size } + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = Modifier.height(400.dp) // Fixed height for emote menu + ) { + Column(modifier = Modifier.fillMaxSize()) { + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + 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) + } + ) + } + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1 + ) { page -> + val items = tabItems[page].items + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 48.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + 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/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 3423dd22e..4fb9c0fce 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -1,5 +1,7 @@ package com.flxrs.dankchat.main.compose.sheets +import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -10,87 +12,117 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab 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.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.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.mention.MentionViewModel import com.flxrs.dankchat.chat.mention.compose.MentionComposable +import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun MentionSheet( + mentionViewModel: MentionComposeViewModel, initialisWhisperTab: Boolean, appearanceSettingsDataStore: AppearanceSettingsDataStore, + inputHeight: androidx.compose.ui.unit.Dp, 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, ) { - val viewModel: MentionViewModel = koinViewModel() val scope = rememberCoroutineScope() val pagerState = rememberPagerState( initialPage = if (initialisWhisperTab) 1 else 0, pageCount = { 2 } ) + var backProgress by remember { mutableFloatStateOf(0f) } - ModalBottomSheet( - onDismissRequest = onDismiss, - modifier = Modifier.fillMaxSize() - ) { - Scaffold( - topBar = { - Column { - TopAppBar( - title = { Text(stringResource(R.string.mentions_title)) }, - navigationIcon = { - IconButton(onClick = onDismiss) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } + LaunchedEffect(pagerState.currentPage) { + mentionViewModel.setCurrentTab(pagerState.currentPage) + } + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onDismiss() + } catch (e: CancellationException) { + backProgress = 0f + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + }, + topBar = { + Column { + TopAppBar( + title = { Text(stringResource(R.string.mentions_title)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } - ) - PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(stringResource(R.string.mentions)) } - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(stringResource(R.string.whispers)) } - ) } - } - } - ) { paddingValues -> - HorizontalPager( - state = pagerState, - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - ) { page -> - MentionComposable( - mentionViewModel = viewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, - isWhisperTab = page == 1, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick ) + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.mentions)) } + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.whispers)) } + ) + } } + }, + ) { paddingValues -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .padding(paddingValues) + .padding(bottom = inputHeight) + .fillMaxSize() + ) { page -> + MentionComposable( + mentionViewModel = mentionViewModel, + appearanceSettingsDataStore = appearanceSettingsDataStore, + isWhisperTab = page == 1, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick + ) } } -} + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index b50c1809d..1d54869d0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.main.compose.sheets -import androidx.compose.foundation.layout.Column +import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -8,19 +9,25 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.replies.compose.RepliesComposable +import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.replies.RepliesUiState import com.flxrs.dankchat.chat.replies.compose.RepliesComposeViewModel import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import kotlinx.coroutines.CancellationException import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -29,6 +36,7 @@ import org.koin.core.parameter.parametersOf fun RepliesSheet( rootMessageId: String, appearanceSettingsDataStore: AppearanceSettingsDataStore, + inputHeight: androidx.compose.ui.unit.Dp, onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -37,47 +45,64 @@ fun RepliesSheet( key = rootMessageId, parameters = { parametersOf(rootMessageId) } ) + var backProgress by remember { mutableFloatStateOf(0f) } - ModalBottomSheet( - onDismissRequest = onDismiss, - modifier = Modifier.fillMaxSize() - ) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.replies_title)) }, - navigationIcon = { - IconButton(onClick = onDismiss) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - } - ) + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress } - ) { paddingValues -> - // Use local ViewModel version of RepliesComposable or similar - // For simplicity, I'll call RepliesComposable but I might need to adjust it to take the new VM - // Or just inline its logic here since we have the new VM. - - val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - val appearanceSettings = appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()).value + onDismiss() + } catch (e: CancellationException) { + backProgress = 0f + } + } - com.flxrs.dankchat.chat.compose.ChatScreen( - messages = when (uiState) { - is com.flxrs.dankchat.chat.replies.RepliesUiState.Found -> uiState.items - else -> emptyList() - }, - fontSize = appearanceSettings.fontSize.toFloat(), - modifier = Modifier.padding(paddingValues).fillMaxSize(), - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = { /* no-op */ } - ) - - if (uiState is com.flxrs.dankchat.chat.replies.RepliesUiState.NotFound) { - androidx.compose.runtime.LaunchedEffect(Unit) { - onDismiss() + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.replies_title)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } } + ) + }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + } + ) { paddingValues -> + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) + + ChatScreen( + messages = when (val state = uiState) { + is RepliesUiState.Found -> state.items + else -> emptyList() + }, + fontSize = appearanceSettings.fontSize.toFloat(), + modifier = Modifier + .padding(paddingValues) + .padding(bottom = inputHeight) + .fillMaxSize(), + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = { /* no-op */ } + ) + + if (uiState is RepliesUiState.NotFound) { + androidx.compose.runtime.LaunchedEffect(Unit) { + onDismiss() } } } + + } 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 2a1add323..e29f6f17d 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 @@ -1,5 +1,6 @@ package com.flxrs.dankchat.preferences.notifications.highlights +import com.flxrs.dankchat.data.database.entity.BadgeHighlightEntity import com.flxrs.dankchat.data.database.entity.BlacklistedUserEntity import com.flxrs.dankchat.data.database.entity.MessageHighlightEntity import com.flxrs.dankchat.data.database.entity.MessageHighlightEntityType @@ -20,6 +21,7 @@ data class MessageHighlightItem( val createNotification: Boolean, val loggedIn: Boolean, val notificationsEnabled: Boolean, + val customColor: Int?, ) : HighlightItem { enum class Type { Username, @@ -45,6 +47,17 @@ data class UserHighlightItem( val username: String, val createNotification: Boolean, val notificationsEnabled: Boolean, + val customColor: Int?, +) : HighlightItem + +data class BadgeHighlightItem( + override val id: Long, + val enabled: Boolean, + val badgeName: String, + val isCustom: Boolean, + val customColor: Int?, + val createNotification: Boolean, + val notificationsEnabled: Boolean, ) : HighlightItem data class BlacklistedUserItem( @@ -64,6 +77,7 @@ fun MessageHighlightEntity.toItem(loggedIn: Boolean, notificationsEnabled: Boole createNotification = createNotification, loggedIn = loggedIn, notificationsEnabled = notificationsEnabled, + customColor = customColor, ) fun MessageHighlightItem.toEntity() = MessageHighlightEntity( @@ -74,6 +88,7 @@ fun MessageHighlightItem.toEntity() = MessageHighlightEntity( isRegex = isRegex, isCaseSensitive = isCaseSensitive, createNotification = createNotification, + customColor = customColor, ) fun MessageHighlightItem.Type.toEntityType(): MessageHighlightEntityType = when (this) { @@ -104,6 +119,7 @@ fun UserHighlightEntity.toItem(notificationsEnabled: Boolean) = UserHighlightIte username = username, createNotification = createNotification, notificationsEnabled = notificationsEnabled, + customColor = customColor, ) fun UserHighlightItem.toEntity() = UserHighlightEntity( @@ -111,6 +127,26 @@ fun UserHighlightItem.toEntity() = UserHighlightEntity( enabled = enabled, username = username, createNotification = createNotification, + customColor = customColor, +) + +fun BadgeHighlightEntity.toItem(notificationsEnabled: Boolean) = BadgeHighlightItem( + id = id, + enabled = enabled, + badgeName = badgeName, + isCustom = isCustom, + customColor = customColor, + createNotification = createNotification, + notificationsEnabled = notificationsEnabled, +) + +fun BadgeHighlightItem.toEntity() = BadgeHighlightEntity( + id = id, + enabled = enabled, + badgeName = badgeName, + isCustom = isCustom, + customColor = customColor, + createNotification = createNotification, ) fun BlacklistedUserEntity.toItem() = BlacklistedUserItem( 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 953093b0c..dca3540e2 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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.isImeVisible 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState @@ -24,18 +25,22 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FabPosition 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.Scaffold import androidx.compose.material3.ScaffoldDefaults @@ -44,15 +49,22 @@ 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.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.SnapshotStateList 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 @@ -61,6 +73,8 @@ import androidx.compose.ui.res.stringResource 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 @@ -69,6 +83,7 @@ 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.utils.compose.animatedAppBarColor +import com.rarepebble.colorpicker.ColorPickerView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn @@ -88,6 +103,7 @@ fun HighlightsScreen(onNavBack: () -> Unit) { currentTab = currentTab, messageHighlights = viewModel.messageHighlights, userHighlights = viewModel.userHighlights, + badgeHighlights = viewModel.badgeHighlights, blacklistedUsers = viewModel.blacklistedUsers, eventsWrapper = events, onSave = viewModel::updateHighlights, @@ -104,9 +120,10 @@ private fun HighlightsScreen( currentTab: HighlightsTab, messageHighlights: SnapshotStateList, userHighlights: SnapshotStateList, + badgeHighlights: SnapshotStateList, blacklistedUsers: SnapshotStateList, eventsWrapper: HighlightEventsWrapper, - onSave: (List, List, List) -> Unit, + onSave: (List, List, List, List) -> Unit, onRemove: (HighlightItem) -> Unit, onAddNew: () -> Unit, onAdd: (HighlightItem, Int) -> Unit, @@ -159,7 +176,7 @@ private fun HighlightsScreen( LifecycleStartEffect(Unit) { onStopOrDispose { - onSave(messageHighlights, userHighlights, blacklistedUsers) + onSave(messageHighlights, userHighlights, blacklistedUsers, badgeHighlights) } } @@ -176,7 +193,7 @@ private fun HighlightsScreen( navigationIcon = { IconButton( onClick = { - onSave(messageHighlights, userHighlights, blacklistedUsers) + onSave(messageHighlights, userHighlights, blacklistedUsers, badgeHighlights) onNavBack() }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, @@ -207,6 +224,7 @@ private fun HighlightsScreen( 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( @@ -225,6 +243,7 @@ private fun HighlightsScreen( 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.BlacklistedUsers -> stringResource(R.string.tab_blacklisted_users) } } @@ -269,6 +288,21 @@ private fun HighlightsScreen( ) } + 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.BlacklistedUsers -> HighlightsList( highlights = blacklistedUsers, listState = listState, @@ -410,6 +444,78 @@ private fun MessageHighlightItem( ) } } + 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 }, + 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) + ) + 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) + } + ) + } + } } } @@ -452,6 +558,71 @@ private fun UserHighlightItem( 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 }, + 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) + ) + 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, @@ -461,6 +632,147 @@ private fun UserHighlightItem( } } +@Composable +private fun BadgeHighlightItem( + item: BadgeHighlightItem, + onChanged: (BadgeHighlightItem) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard(modifier) { + Row { + Column( + modifier = Modifier + .weight(1f) + .padding(8.dp), + ) { + if (item.isCustom) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = item.badgeName, + onValueChange = { onChanged(item.copy(badgeName = it)) }, + label = { Text(stringResource(R.string.badge)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + maxLines = 1, + ) + } 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) + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.align(Alignment.Center) + ) + } + } + FlowRow( + modifier = Modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.Center, + ) { + CheckboxWithText( + text = stringResource(R.string.enabled), + checked = item.enabled, + onCheckedChange = { onChanged(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)) }, + 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 }, + 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) + ) + 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( + onClick = onRemove, + content = { Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.clear)) }, + ) + } + } + } +} + @Composable private fun BlacklistedUserItem( item: BlacklistedUserItem, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsTab.kt index 1c1907e9d..bc4f8fa2e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsTab.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsTab.kt @@ -4,4 +4,5 @@ enum class HighlightsTab { Messages, Users, BlacklistedUsers, + Badges, } 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 56ec9eda7..99699ce75 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 @@ -30,6 +30,7 @@ class HighlightsViewModel( val messageHighlights = SnapshotStateList() val userHighlights = SnapshotStateList() + val badgeHighlights = SnapshotStateList() val blacklistedUsers = SnapshotStateList() val events = eventChannel.receiveAsFlow() @@ -44,10 +45,12 @@ class HighlightsViewModel( val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications val messageHighlightItems = highlightsRepository.messageHighlights.value.map { it.toItem(loggedIn, notificationsEnabled) } val userHighlightItems = highlightsRepository.userHighlights.value.map { it.toItem(notificationsEnabled) } + val badgeHighlightItems = highlightsRepository.badgeHighlights.value.map { it.toItem(notificationsEnabled) } val blacklistedUserItems = highlightsRepository.blacklistedUsers.value.map { it.toItem() } messageHighlights.replaceAll(messageHighlightItems) userHighlights.replaceAll(userHighlightItems) + badgeHighlights.replaceAll(badgeHighlightItems) blacklistedUsers.replaceAll(blacklistedUserItems) } @@ -68,6 +71,12 @@ class HighlightsViewModel( position = userHighlights.lastIndex } + HighlightsTab.Badges -> { + val entity = highlightsRepository.addBadgeHighlight() + badgeHighlights += entity.toItem(notificationsEnabled) + position = badgeHighlights.lastIndex + } + HighlightsTab.BlacklistedUsers -> { val entity = highlightsRepository.addBlacklistedUser() blacklistedUsers += entity.toItem() @@ -92,6 +101,12 @@ class HighlightsViewModel( isLast = position == userHighlights.lastIndex } + is BadgeHighlightItem -> { + highlightsRepository.updateBadgeHighlight(item.toEntity()) + badgeHighlights.add(position, item) + isLast = position == badgeHighlights.lastIndex + } + is BlacklistedUserItem -> { highlightsRepository.updateBlacklistedUser(item.toEntity()) blacklistedUsers.add(position, item) @@ -116,6 +131,12 @@ class HighlightsViewModel( userHighlights.removeAt(position) } + is BadgeHighlightItem -> { + position = badgeHighlights.indexOfFirst { it.id == item.id } + highlightsRepository.removeBadgeHighlight(item.toEntity()) + badgeHighlights.removeAt(position) + } + is BlacklistedUserItem -> { position = blacklistedUsers.indexOfFirst { it.id == item.id } highlightsRepository.removeBlacklistedUser(item.toEntity()) @@ -129,6 +150,7 @@ class HighlightsViewModel( messageHighlightItems: List, userHighlightItems: List, blacklistedUserHighlightItems: List, + badgeHighlightItems: List, ) = viewModelScope.launch { filterMessageHighlights(messageHighlightItems).let { (blankEntities, entities) -> highlightsRepository.updateMessageHighlights(entities) @@ -140,6 +162,11 @@ class HighlightsViewModel( blankEntities.forEach { highlightsRepository.removeUserHighlight(it) } } + filterBadgeHighlights(badgeHighlightItems).let { (blankEntities, entities) -> + highlightsRepository.updateBadgeHighlights(entities) + blankEntities.forEach { highlightsRepository.removeBadgeHighlight(it) } + } + filterBlacklistedUsers(blacklistedUserHighlightItems).let { (blankEntities, entities) -> highlightsRepository.updateBlacklistedUser(entities) blankEntities.forEach { highlightsRepository.removeBlacklistedUser(it) } @@ -154,6 +181,10 @@ class HighlightsViewModel( .map { it.toEntity() } .partition { it.username.isBlank() } + private fun filterBadgeHighlights(items: List) = items + .map { it.toEntity() } + .partition { it.badgeName.isBlank() } + private fun filterBlacklistedUsers(items: List) = items .map { it.toEntity() } .partition { it.username.isBlank() } 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 236555f9c..69c4edf10 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 @@ -135,11 +135,11 @@ 登出 禁言 刪除留言 - 永久禁言 + Ban 解ban 聊天室狀態 確認永ban - 您確定要永久禁言這名用戶嗎? + 您確定要永ban這名用戶嗎? Ban 確認禁言 禁言 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 03b0c8b54..e1af229f6 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -322,10 +322,12 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Nutzer Blockierte Nutzer Twitch + Abzeichen Rückgängig Eintrag entfernt Benutzer %1$s entblockt Nutzer %1$s konnte nicht entblockt werden + Abzeichen Nutzer %1$s konnte nicht geblockt werden Dein Benutzername Abonnements und Events @@ -338,6 +340,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Erzeugt Benachrichtigungen und hebt Nachrichten nach bestimmten Mustern hervor. Erzeugt Benachrichtigungen und hebt Nachrichten von bestimmten Benutzern hervor. Deaktiviert Benachrichtigungen und Hervorhebungen von bestimmten Nutzern (z. B. Bots). + Erzeugt Benachrichtigungen und hebt Nachrichten basierend auf Abzeichen hervor. Ignoriert Nachrichten nach bestimmten Mustern. Ignoriert Nachrichten von bestimmten Nutzern. Blockierte Twitch-Nutzer verwalten @@ -402,4 +405,13 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Stream-Kategorie anzeigen Zeigt auch die Stream-Kategorie Eingabefeld ein-/ausblenden + Broadcaster + Admin + Mitarbeiter + Moderator + Haupt-Moderator + Verifiziert + VIP + Gründer + Abonnent diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4683294e9..bc5064543 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -315,10 +315,12 @@ 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 @@ -331,6 +333,7 @@ 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. @@ -395,4 +398,13 @@ Show stream category Also display stream category Toggle input + Broadcaster + Admin + Staff + Moderator + Lead Moderator + Verified + VIP + Founder + Subscriber diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f0ada588..596550ca3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,7 @@ FeelsDankMan DankChat running in the background Open the emote menu + Emotes Login to Twitch.tv Start chatting Disconnected @@ -374,10 +375,12 @@ 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 @@ -390,6 +393,7 @@ 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. @@ -455,4 +459,16 @@ 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e1113280..23449a2b5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From b56cbe2de9cdf34eb8c54b01b2ca8675aeee9d08 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 011/349] feat(compose): Add fullscreen sheets with predictive back --- .../dankchat/data/repo/chat/ChatRepository.kt | 3 + .../compose/ChannelManagementViewModel.kt | 5 +- .../dankchat/main/compose/ChatInputLayout.kt | 87 ++--------- .../main/compose/ChatInputViewModel.kt | 14 +- .../flxrs/dankchat/main/compose/MainAppBar.kt | 10 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 26 ++-- .../compose/dialogs/ManageChannelsDialog.kt | 136 +++++++++++------- .../main/compose/sheets/MentionSheet.kt | 2 - .../main/compose/sheets/RepliesSheet.kt | 6 +- 9 files changed, 123 insertions(+), 166 deletions(-) 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..e2726d081 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 @@ -869,6 +869,9 @@ class ChatRepository( .distinctBy { it.message.id } .sortedBy { it.message.timestamp } } + if (mentions.isNotEmpty()) { + _channelMentionCount.increment(channel, mentions.size) + } usersRepository.updateUsers(channel, userSuggestions) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index 3a62e5cad..b72c7063a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -40,10 +40,11 @@ class ChannelManagementViewModel( } } - // Auto-load data when channels added + // Auto-load data when channels added and join if necessary viewModelScope.launch { channels.collect { channelList -> channelList.forEach { channelWithRename -> + chatRepository.joinChannel(channelWithRename.channel) channelDataCoordinator.loadChannelData(channelWithRename.channel) } } @@ -54,12 +55,14 @@ class ChannelManagementViewModel( val current = preferenceStore.channels if (channel !in current) { preferenceStore.channels = current + channel + chatRepository.joinChannel(channel) chatRepository.setActiveChannel(channel) } } fun removeChannel(channel: UserName) { preferenceStore.removeChannel(channel) + chatRepository.updateChannels(preferenceStore.channels) channelDataCoordinator.cleanupChannel(channel) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 5b93b2d7b..fb0b3a304 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -66,9 +66,7 @@ fun ChatInputLayout( showReplyOverlay: Boolean, replyName: UserName?, onSend: () -> Unit, - onLongSend: () -> Unit, - onSendHold: (Boolean) -> Unit, - isRepeatedSendEnabled: Boolean, + onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, onReplyDismiss: () -> Unit, modifier: Modifier = Modifier @@ -172,34 +170,16 @@ fun ChatInputLayout( Spacer(modifier = Modifier.weight(1f)) - // History Button (Only when empty) - AnimatedVisibility( - visible = textFieldState.text.isEmpty(), - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut() - ) { - IconButton( - onClick = onLongSend, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.History, - contentDescription = stringResource(R.string.resume_scroll), // Using resume_scroll as a placeholder for "History/Last message" context - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Spam Button (Only when not empty and enabled) - AnimatedVisibility( - visible = textFieldState.text.isNotEmpty() && isRepeatedSendEnabled, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut() + // History Button (Always visible) + IconButton( + onClick = onLastMessageClick, + enabled = enabled, + modifier = Modifier.size(40.dp) ) { - SpamButton( - enabled = enabled, - onSendHold = onSendHold + Icon( + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.resume_scroll), // Using resume_scroll as a placeholder for "History/Last message" context + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -214,53 +194,6 @@ fun ChatInputLayout( } } -@Composable -private fun SpamButton( - enabled: Boolean, - onSendHold: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - val interactionSource = remember { MutableInteractionSource() } - - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .size(40.dp) - .clip(CircleShape) - .indication( - interactionSource = interactionSource, - indication = ripple() - ) - .pointerInput(enabled) { - if (!enabled) return@pointerInput - - awaitEachGesture { - val down = awaitFirstDown() - var isHolding = false - val timerJob = scope.launch { - delay(500L) - isHolding = true - onSendHold(true) - } - - val up = waitForUpOrCancellation() - timerJob.cancel() - - if (up != null || isHolding) { - onSendHold(false) - } - } - } - ) { - Icon( - imageVector = Icons.Default.Repeat, - contentDescription = null, // TODO: Add string for "Spam" - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - @Composable private fun SendButton( enabled: Boolean, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 680bfa7bb..e38595d57 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -225,7 +225,10 @@ class ChatInputViewModel( is CommandResult.Blocked -> Unit is CommandResult.IrcCommand, - is CommandResult.NotFound -> chatRepository.sendMessage(message, replyIdOrNull) + is CommandResult.NotFound -> { + chatRepository.sendMessage(message, replyIdOrNull) + setReplying(false) + } is CommandResult.AcceptedTwitchCommand -> { if (commandResult.command == TwitchCommand.Whisper) { @@ -237,7 +240,10 @@ class ChatInputViewModel( } is CommandResult.AcceptedWithResponse -> chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) - is CommandResult.Message -> chatRepository.sendMessage(commandResult.message, replyIdOrNull) + is CommandResult.Message -> { + chatRepository.sendMessage(commandResult.message, replyIdOrNull) + setReplying(false) + } } if (commandResult != CommandResult.NotFound && commandResult != CommandResult.IrcCommand) { @@ -246,10 +252,6 @@ class ChatInputViewModel( } fun getLastMessage() { - if (textFieldState.text.isNotBlank()) { - return - } - val lastMessage = chatRepository.getLastMessage() ?: return textFieldState.edit { replace(0, length, lastMessage) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index aad312eb2..fbbb3935d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -95,7 +95,8 @@ fun MainAppBar( DropdownMenu( expanded = currentMenu != null, - onDismissRequest = { currentMenu = null } + onDismissRequest = { currentMenu = null }, + shape = MaterialTheme.shapes.medium ) { AnimatedContent( targetState = currentMenu, @@ -124,13 +125,6 @@ fun MainAppBar( text = { Text(stringResource(R.string.account)) }, onClick = { currentMenu = AppBarMenu.Account } ) - DropdownMenuItem( - text = { Text(stringResource(R.string.whispers)) }, - onClick = { - onOpenWhispers() - currentMenu = null - } - ) } DropdownMenuItem( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index f6ae05dcb..4b4665715 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -439,7 +440,10 @@ fun MainScreen( ) val density = LocalDensity.current var inputHeightPx by remember { mutableIntStateOf(0) } + var inputTopY by remember { mutableStateOf(0f) } + var containerHeight by remember { mutableIntStateOf(0) } val inputHeightDp = with(density) { inputHeightPx.toDp() } + val sheetBottomPadding = with(density) { (containerHeight - inputTopY).toDp() } // Track keyboard visibility - clear focus only when keyboard is fully closed val focusManager = LocalFocusManager.current @@ -472,7 +476,10 @@ fun MainScreen( val systemBarsPaddingModifier = if (isFullscreen) Modifier else Modifier.statusBarsPadding() - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { containerHeight = it.size.height } + ) { Scaffold( modifier = modifier .fillMaxSize() @@ -531,15 +538,14 @@ fun MainScreen( showReplyOverlay = inputState.showReplyOverlay, replyName = inputState.replyName, onSend = chatInputViewModel::sendMessage, - onLongSend = chatInputViewModel::getLastMessage, - onSendHold = chatInputViewModel::setRepeatedSend, - isRepeatedSendEnabled = isRepeatedSendEnabled, + onLastMessageClick = chatInputViewModel::getLastMessage, onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, onReplyDismiss = { chatInputViewModel.setReplying(false) }, modifier = Modifier.onGloballyPositioned { coordinates -> inputHeightPx = coordinates.size.height + inputTopY = coordinates.positionInRoot().y } ) } @@ -595,7 +601,8 @@ fun MainScreen( } HorizontalPager( state = composePagerState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + key = { index -> pagerState.channels.getOrNull(index)?.value ?: index } ) { page -> if (page in pagerState.channels.indices) { val channel = pagerState.channels[page] @@ -641,12 +648,10 @@ fun MainScreen( exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), modifier = Modifier .fillMaxSize() - .imePadding() + .padding(bottom = sheetBottomPadding) ) { Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + modifier = Modifier.fillMaxSize() ) { when (val state = fullScreenSheetState) { is FullScreenSheetState.Closed -> Unit @@ -655,7 +660,6 @@ fun MainScreen( mentionViewModel = mentionViewModel, initialisWhisperTab = false, appearanceSettingsDataStore = appearanceSettingsDataStore, - inputHeight = inputHeightDp, onDismiss = sheetNavigationViewModel::closeFullScreenSheet, onUserClick = { userId, userName, displayName, channel, badges, _ -> userPopupParams = UserPopupStateParams( @@ -686,7 +690,6 @@ fun MainScreen( mentionViewModel = mentionViewModel, initialisWhisperTab = true, appearanceSettingsDataStore = appearanceSettingsDataStore, - inputHeight = inputHeightDp, onDismiss = sheetNavigationViewModel::closeFullScreenSheet, onUserClick = { userId, userName, displayName, channel, badges, _ -> userPopupParams = UserPopupStateParams( @@ -717,7 +720,6 @@ fun MainScreen( RepliesSheet( rootMessageId = state.replyMessageId, appearanceSettingsDataStore = appearanceSettingsDataStore, - inputHeight = inputHeightDp, onDismiss = { sheetNavigationViewModel.closeFullScreenSheet() chatInputViewModel.setReplying(false) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt index 527856c70..89e524600 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt @@ -1,7 +1,7 @@ package com.flxrs.dankchat.main.compose.dialogs +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -10,8 +10,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -20,13 +18,20 @@ import androidx.compose.material3.ModalBottomSheet 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.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.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -34,6 +39,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.preferences.model.ChannelWithRename @@ -50,6 +56,14 @@ fun ManageChannelsDialog( ) { var channelToDelete by remember { mutableStateOf(null) } var channelToEdit by remember { mutableStateOf(null) } + + // Local state for smooth reordering + val localChannels = remember { mutableStateListOf() } + // Update local channels when external source changes + LaunchedEffect(channels) { + localChannels.clear() + localChannels.addAll(channels) + } ModalBottomSheet( onDismissRequest = onDismiss, @@ -69,29 +83,46 @@ fun ManageChannelsDialog( modifier = Modifier.fillMaxWidth(), state = rememberLazyListState() ) { - itemsIndexed(channels, key = { _, item -> item.channel.value }) { index, channelWithRename -> + itemsIndexed(localChannels, key = { _, item -> item.channel.value }) { index, channelWithRename -> + var offsetY by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + val itemHeight = remember { mutableIntStateOf(0) } + ChannelItem( channelWithRename = channelWithRename, - onEdit = { channelToEdit = channelWithRename }, - onDelete = { channelToDelete = channelWithRename.channel }, - onMoveUp = if (index > 0) { - { - val newList = channels.toMutableList() - Collections.swap(newList, index, index - 1) - onReorder(newList) + isDragging = isDragging, + offsetY = offsetY, + onGloballyPositioned = { coordinates -> + itemHeight.intValue = coordinates.size.height + }, + onDragStart = { isDragging = true }, + onDragEnd = { + isDragging = false + offsetY = 0f + onReorder(localChannels.toList()) + }, + onDrag = { change, dragAmount -> + change.consume() + offsetY += dragAmount.y + + val height = itemHeight.intValue.toFloat() + if (height > 0) { + val threshold = height * 0.5f // Swap slightly earlier than full height + if (offsetY > threshold && index < localChannels.lastIndex) { + Collections.swap(localChannels, index, index + 1) + offsetY -= height + } else if (offsetY < -threshold && index > 0) { + Collections.swap(localChannels, index, index - 1) + offsetY += height + } } - } else null, - onMoveDown = if (index < channels.size - 1) { - { - val newList = channels.toMutableList() - Collections.swap(newList, index, index + 1) - onReorder(newList) - } - } else null + }, + onEdit = { channelToEdit = channelWithRename }, + onDelete = { channelToDelete = channelWithRename.channel } ) } - if (channels.isEmpty()) { + if (localChannels.isEmpty()) { item { Text( text = stringResource(R.string.no_channels_added), @@ -139,51 +170,46 @@ fun ManageChannelsDialog( @Composable private fun ChannelItem( channelWithRename: ChannelWithRename, + isDragging: Boolean, + offsetY: Float, + onGloballyPositioned: (androidx.compose.ui.layout.LayoutCoordinates) -> Unit, + onDragStart: (androidx.compose.ui.geometry.Offset) -> Unit, + onDragEnd: () -> Unit, + onDrag: (androidx.compose.ui.input.pointer.PointerInputChange, androidx.compose.ui.geometry.Offset) -> Unit, onEdit: () -> Unit, - onDelete: () -> Unit, - onMoveUp: (() -> Unit)?, - onMoveDown: (() -> Unit)? + onDelete: () -> Unit ) { - var showReorderMenu by remember { mutableStateOf(false) } - + val density = LocalDensity.current + val elevation = if (isDragging) 8.dp else 0.dp + Row( modifier = Modifier .fillMaxWidth() + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { + translationY = offsetY + shadowElevation = with(density) { elevation.toPx() } + } + .background(if (isDragging) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.surface) + .onGloballyPositioned(onGloballyPositioned) .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - Box { - IconButton(onClick = { showReorderMenu = true }) { - Icon( - painter = painterResource(R.drawable.ic_drag_handle), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - DropdownMenu( - expanded = showReorderMenu, - onDismissRequest = { showReorderMenu = false } - ) { - if (onMoveUp != null) { - DropdownMenuItem( - text = { Text("Move Up") }, - onClick = { - onMoveUp() - showReorderMenu = false - } - ) - } - if (onMoveDown != null) { - DropdownMenuItem( - text = { Text("Move Down") }, - onClick = { - onMoveDown() - showReorderMenu = false - } + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragEnd, + onDrag = onDrag ) } - } - } + .padding(8.dp) + ) Text( text = buildAnnotatedString { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 4fb9c0fce..8a688ec10 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -43,7 +43,6 @@ fun MentionSheet( mentionViewModel: MentionComposeViewModel, initialisWhisperTab: Boolean, appearanceSettingsDataStore: AppearanceSettingsDataStore, - inputHeight: androidx.compose.ui.unit.Dp, onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -110,7 +109,6 @@ fun MentionSheet( state = pagerState, modifier = Modifier .padding(paddingValues) - .padding(bottom = inputHeight) .fillMaxSize() ) { page -> MentionComposable( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index 1d54869d0..f06cdf452 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -36,7 +36,6 @@ import org.koin.core.parameter.parametersOf fun RepliesSheet( rootMessageId: String, appearanceSettingsDataStore: AppearanceSettingsDataStore, - inputHeight: androidx.compose.ui.unit.Dp, onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -88,10 +87,7 @@ fun RepliesSheet( else -> emptyList() }, fontSize = appearanceSettings.fontSize.toFloat(), - modifier = Modifier - .padding(paddingValues) - .padding(bottom = inputHeight) - .fillMaxSize(), + modifier = Modifier.padding(paddingValues).fillMaxSize(), onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = { /* no-op */ } From c2a73ef535d30a5b635c69142604bb27798507d7 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 012/349] feat(compose): Redesign emote menu as keyboard replacement --- app/build.gradle.kts | 1 + .../dankchat/data/repo/chat/ChatRepository.kt | 3 - .../compose/ChannelManagementViewModel.kt | 44 +- .../flxrs/dankchat/main/compose/ChannelTab.kt | 8 +- .../dankchat/main/compose/ChatInputLayout.kt | 40 +- .../main/compose/ChatInputViewModel.kt | 40 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 658 ++++++++++-------- .../compose/dialogs/ManageChannelsDialog.kt | 200 +++--- .../dankchat/main/compose/sheets/EmoteMenu.kt | 158 +++++ .../main/compose/sheets/MentionSheet.kt | 2 + .../main/compose/sheets/RepliesSheet.kt | 3 + .../preferences/DankChatPreferenceStore.kt | 10 + app/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 3 + 14 files changed, 760 insertions(+), 412 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aeced4646..0919e240c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -222,6 +222,7 @@ dependencies { implementation(libs.process.phoenix) implementation(libs.autolinktext) implementation(libs.aboutlibraries.compose.m3) + implementation(libs.reorderable) // Test testImplementation(libs.junit.jupiter.api) 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 e2726d081..a06a26c59 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 @@ -869,9 +869,6 @@ class ChatRepository( .distinctBy { it.message.id } .sortedBy { it.message.timestamp } } - if (mentions.isNotEmpty()) { - _channelMentionCount.increment(channel, mentions.size) - } usersRepository.updateUsers(channel, userSuggestions) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index b72c7063a..9e6099f3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -42,11 +42,16 @@ class ChannelManagementViewModel( // Auto-load data when channels added and join if necessary viewModelScope.launch { + var previousChannels = emptySet() channels.collect { channelList -> - channelList.forEach { channelWithRename -> - chatRepository.joinChannel(channelWithRename.channel) - channelDataCoordinator.loadChannelData(channelWithRename.channel) + val currentChannels = channelList.map { it.channel }.toSet() + val newChannels = currentChannels - previousChannels + + newChannels.forEach { channel -> + chatRepository.joinChannel(channel) + channelDataCoordinator.loadChannelData(channel) } + previousChannels = currentChannels } } } @@ -103,7 +108,36 @@ class ChannelManagementViewModel( } } - fun reorderChannels(channels: List) { - preferenceStore.channels = channels.map { it.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 = chatRepository.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() + chatRepository.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/main/compose/ChannelTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt index d0f6f5b68..18e3e4b5f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt @@ -14,9 +14,10 @@ fun ChannelTab( ) { val tabColor = when { tab.isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 -> MaterialTheme.colorScheme.error - tab.hasUnread -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.onSurfaceVariant + // Unread or Mentioned -> High visibility (OnSurface) + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + // Idle -> Lower visibility + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) } Tab( @@ -28,7 +29,6 @@ fun ChannelTab( BadgedBox( badge = { if (tab.mentionCount > 0) { - // TODO could add mention count as text Badge() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index fb0b3a304..3e2088395 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -57,6 +57,12 @@ import com.flxrs.dankchat.main.InputState import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.material.icons.filled.Keyboard + @Composable fun ChatInputLayout( textFieldState: TextFieldState, @@ -65,12 +71,14 @@ fun ChatInputLayout( canSend: Boolean, showReplyOverlay: Boolean, replyName: UserName?, + isEmoteMenuOpen: Boolean, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, onReplyDismiss: () -> Unit, modifier: Modifier = Modifier ) { + val focusRequester = remember { FocusRequester() } val hint = when (inputState) { InputState.Default -> stringResource(R.string.hint_connected) InputState.Replying -> stringResource(R.string.hint_replying) @@ -131,6 +139,7 @@ fun ChatInputLayout( state = textFieldState, modifier = Modifier .fillMaxWidth() + .focusRequester(focusRequester) .padding(bottom = 0.dp), // Reduce bottom padding as actions are below label = { Text(hint) }, colors = TextFieldDefaults.colors( @@ -145,7 +154,9 @@ fun ChatInputLayout( lineLimits = TextFieldLineLimits.MultiLine( minHeightInLines = 1, maxHeightInLines = 5 - ) + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + onKeyboardAction = { if (canSend) onSend() } ) // Actions Row @@ -155,17 +166,30 @@ fun ChatInputLayout( .fillMaxWidth() .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) ) { - // Emote Button (Left) + // Emote/Keyboard Button (Left) IconButton( - onClick = onEmoteClick, + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() + } + onEmoteClick() + }, enabled = enabled, modifier = Modifier.size(40.dp) ) { - Icon( - imageVector = Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.emote_menu_hint), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (isEmoteMenuOpen) { + Icon( + imageVector = Icons.Default.Keyboard, + contentDescription = stringResource(R.string.dialog_dismiss), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = stringResource(R.string.emote_menu_hint), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index e38595d57..970df18f1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -28,6 +28,7 @@ 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 @@ -62,6 +63,8 @@ class ChatInputViewModel( private val repeatedSend = MutableStateFlow(RepeatedSendData(enabled = false, message = "")) private val fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) private val mentionSheetTab = MutableStateFlow(0) + private val _isEmoteMenuOpen = MutableStateFlow(false) + val isEmoteMenuOpen = _isEmoteMenuOpen.asStateFlow() // Create flow from TextFieldState private val textFlow = snapshotFlow { textFieldState.text.toString() } @@ -115,7 +118,8 @@ class ChatInputViewModel( val tab: Int, val isReplying: Boolean, val replyName: UserName?, - val replyMessageId: String? + val replyMessageId: String?, + val isEmoteMenuOpen: Boolean ) fun uiState(fullScreenSheetState: StateFlow, mentionSheetTab: StateFlow): StateFlow { @@ -134,20 +138,28 @@ class ChatInputViewModel( UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn) } - val sheetAndReplyFlow = combine( - fullScreenSheetState, - mentionSheetTab, + val replyStateFlow = combine( _isReplying, _replyName, _replyMessageId - ) { sheetState, tab, isReplying, replyName, replyMessageId -> - SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId) + ) { isReplying, replyName, replyMessageId -> + Triple(isReplying, replyName, replyMessageId) + } + + val sheetAndReplyFlow = combine( + fullScreenSheetState, + mentionSheetTab, + replyStateFlow, + _isEmoteMenuOpen + ) { sheetState, tab, replyState, isEmoteMenuOpen -> + val (isReplying, replyName, replyMessageId) = replyState + SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen) } _uiState = combine( baseFlow, sheetAndReplyFlow - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId) -> + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen) -> this.fullScreenSheetState.value = sheetState this.mentionSheetTab.value = tab @@ -181,7 +193,8 @@ class ChatInputViewModel( inputState = inputState, showReplyOverlay = showReplyOverlay, replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, - replyName = effectiveReplyName + replyName = effectiveReplyName, + isEmoteMenuOpen = isEmoteMenuOpen ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) @@ -314,6 +327,14 @@ class ChatInputViewModel( } } + fun toggleEmoteMenu() { + _isEmoteMenuOpen.update { !it } + } + + fun setEmoteMenuOpen(open: Boolean) { + _isEmoteMenuOpen.value = open + } + companion object { private const val SUGGESTION_DEBOUNCE_MS = 20L } @@ -331,4 +352,5 @@ data class ChatInputUiState( val showReplyOverlay: Boolean = false, val replyMessageId: String? = null, val replyName: UserName? = null, -) \ No newline at end of file + val isEmoteMenuOpen: Boolean = false +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 4b4665715..4e857ae93 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -1,5 +1,12 @@ package com.flxrs.dankchat.main.compose +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import com.flxrs.dankchat.main.compose.sheets.EmoteMenu + import android.content.ClipData import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -13,6 +20,7 @@ import androidx.compose.foundation.layout.Column 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.imeAnimationTarget import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.isImeVisible @@ -42,6 +50,7 @@ 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.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.ClipEntry @@ -53,6 +62,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState @@ -91,7 +101,6 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -102,6 +111,11 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.Dispatchers + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun MainScreen( @@ -122,6 +136,7 @@ fun MainScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current + val density = LocalDensity.current val clipboardManager = LocalClipboard.current // Scoped ViewModels - each handles one concern val mainScreenViewModel: MainScreenViewModel = koinViewModel() @@ -138,10 +153,71 @@ fun MainScreen( val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current + val keyboardController = LocalSoftwareKeyboardController.current + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = developerSettingsDataStore.current()) val isRepeatedSendEnabled = developerSettings.repeatedSending + var keyboardHeightPx by remember(isLandscape) { + val persisted = if (isLandscape) preferenceStore.keyboardHeightLandscape else preferenceStore.keyboardHeightPortrait + mutableIntStateOf(persisted) + } + + 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 = androidx.compose.runtime.rememberUpdatedState(currentImeHeight) + val isImeVisible = WindowInsets.isImeVisible + + LaunchedEffect(isLandscape, density) { + snapshotFlow { + (imeTarget.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) + } + .debounce(300) + .collect { height -> + val minHeight = with(density) { 100.dp.toPx() } + if (height > minHeight) { + keyboardHeightPx = height + if (isLandscape) { + preferenceStore.keyboardHeightLandscape = height + } else { + preferenceStore.keyboardHeightPortrait = height + } + } + } + } + + LaunchedEffect(isImeVisible) { + if (isImeVisible) { + chatInputViewModel.setEmoteMenuOpen(false) + } + } + + val inputState by chatInputViewModel.uiState(sheetNavigationViewModel.fullScreenSheetState, mentionViewModel.currentTab).collectAsStateWithLifecycle() + val isKeyboardVisible = isImeVisible || isImeOpening + var backProgress by remember { mutableStateOf(0f) } + + // Disable if Keyboard is open or opening to prevent conflict + PredictiveBackHandler(enabled = inputState.isEmoteMenuOpen && !isKeyboardVisible) { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + chatInputViewModel.setEmoteMenuOpen(false) + backProgress = 0f + } catch (e: Exception) { + backProgress = 0f + } + } + var showAddChannelDialog by remember { mutableStateOf(false) } var showManageChannelsDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) } @@ -164,7 +240,7 @@ fun MainScreen( } } - // Handle Login Result (previously in handleLoginRequest) + // Handle Login Result val navBackStackEntry = navController.currentBackStackEntry val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } LaunchedEffect(loginSuccess) { @@ -215,9 +291,7 @@ fun MainScreen( val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() ManageChannelsDialog( channels = channels, - onRemoveChannel = channelManagementViewModel::removeChannel, - onRenameChannel = channelManagementViewModel::renameChannel, - onReorder = channelManagementViewModel::reorderChannels, + onApplyChanges = channelManagementViewModel::applyChanges, onDismiss = { showManageChannelsDialog = false } ) } @@ -392,17 +466,6 @@ fun MainScreen( ) } - if (inputSheetState is InputSheetState.EmoteMenu) { - EmoteMenuSheet( - onDismiss = sheetNavigationViewModel::closeInputSheet, - onEmoteClick = { code, _ -> - chatInputViewModel.insertText("$code ") - sheetNavigationViewModel.closeInputSheet() - }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) - } - if (inputSheetState is InputSheetState.MoreActions) { val state = inputSheetState as InputSheetState.MoreActions com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( @@ -432,13 +495,11 @@ fun MainScreen( val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() - val inputState by chatInputViewModel.uiState(sheetNavigationViewModel.fullScreenSheetState, mentionViewModel.currentTab).collectAsStateWithLifecycle() val composePagerState = rememberPagerState( initialPage = pagerState.currentPage, pageCount = { pagerState.channels.size } ) - val density = LocalDensity.current var inputHeightPx by remember { mutableIntStateOf(0) } var inputTopY by remember { mutableStateOf(0f) } var containerHeight by remember { mutableIntStateOf(0) } @@ -458,10 +519,11 @@ fun MainScreen( // Sync Compose pager with ViewModel state LaunchedEffect(pagerState.currentPage) { - if (composePagerState.currentPage != pagerState.currentPage && + if (!composePagerState.isScrollInProgress && + composePagerState.currentPage != pagerState.currentPage && pagerState.currentPage < pagerState.channels.size ) { - composePagerState.animateScrollToPage(pagerState.currentPage) + composePagerState.scrollToPage(pagerState.currentPage) } } @@ -472,293 +534,339 @@ fun MainScreen( } } - val isKeyboardVisible = WindowInsets.isImeVisible || imeAnimationTarget.getBottom(density) > 0 - val systemBarsPaddingModifier = if (isFullscreen) Modifier else Modifier.statusBarsPadding() Box(modifier = Modifier .fillMaxSize() .onGloballyPositioned { containerHeight = it.size.height } ) { + val currentImeHeightDp = with(density) { currentImeHeight.toDp() } + val targetImeHeightDp = with(density) { targetImeHeight.toDp() } + + 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) + + val scaffoldBottomPadding = max( + targetImeHeightDp, + max(currentImeHeightDp, if (inputState.isEmoteMenuOpen) targetMenuHeight else 0.dp) + ) + Scaffold( modifier = modifier .fillMaxSize() .then(systemBarsPaddingModifier) - .imePadding(), - topBar = { - if (tabState.tabs.isEmpty()) { - return@Scaffold - } + .padding(bottom = scaffoldBottomPadding), + contentWindowInsets = WindowInsets.statusBars, + topBar = { + if (tabState.tabs.isEmpty()) { + return@Scaffold + } - AnimatedVisibility( - visible = showAppBar && !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - MainAppBar( - isLoggedIn = isLoggedIn, - totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onAddChannel = { showAddChannelDialog = true }, - onOpenMentions = { sheetNavigationViewModel.openMentions() }, - onOpenWhispers = { sheetNavigationViewModel.openWhispers() }, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = { showLogoutDialog = true }, - onManageChannels = { showManageChannelsDialog = true }, - onOpenChannel = onOpenChannel, - onRemoveChannel = { showRemoveChannelDialog = true }, - onReportChannel = onReportChannel, - onBlockChannel = { showBlockChannelDialog = true }, - onReloadEmotes = { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() - }, - onReconnect = { - channelManagementViewModel.reconnect() - onReconnect() - }, - onClearChat = { showClearChatDialog = true }, - onOpenSettings = onNavigateToSettings - ) - } - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - bottomBar = { - AnimatedVisibility( - visible = showInputState && !isFullscreen, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - Box(modifier = Modifier.fillMaxWidth()) { - ChatInputLayout( - textFieldState = chatInputViewModel.textFieldState, - inputState = inputState.inputState, - enabled = inputState.enabled, - canSend = inputState.canSend, - showReplyOverlay = inputState.showReplyOverlay, - replyName = inputState.replyName, - onSend = chatInputViewModel::sendMessage, - onLastMessageClick = chatInputViewModel::getLastMessage, - onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, - onReplyDismiss = { - chatInputViewModel.setReplying(false) + AnimatedVisibility( + visible = showAppBar && !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + MainAppBar( + isLoggedIn = isLoggedIn, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, + onAddChannel = { showAddChannelDialog = true }, + onOpenMentions = { sheetNavigationViewModel.openMentions() }, + onOpenWhispers = { sheetNavigationViewModel.openWhispers() }, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = { showLogoutDialog = true }, + onManageChannels = { showManageChannelsDialog = true }, + onOpenChannel = onOpenChannel, + onRemoveChannel = { showRemoveChannelDialog = true }, + onReportChannel = onReportChannel, + onBlockChannel = { showBlockChannelDialog = true }, + onReloadEmotes = { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + onReloadEmotes() }, - modifier = Modifier.onGloballyPositioned { coordinates -> - inputHeightPx = coordinates.size.height - inputTopY = coordinates.positionInRoot().y - } + onReconnect = { + channelManagementViewModel.reconnect() + onReconnect() + }, + onClearChat = { showClearChatDialog = true }, + onOpenSettings = onNavigateToSettings ) } - } - } - ) { paddingValues -> - // Main content of the chat (tabs, pager, empty state) - Box(modifier = Modifier.fillMaxSize()) { // This box gets the Scaffold's content padding - val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() - DankBackground(visible = showFullScreenLoading) - if (showFullScreenLoading) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - ) - return@Scaffold - } - if (tabState.tabs.isEmpty() && !tabState.loading) { - EmptyStateContent( - isLoggedIn = isLoggedIn, - onAddChannel = { showAddChannelDialog = true }, - onLogin = onLogin, - onToggleAppBar = mainScreenViewModel::toggleAppBar, - onToggleFullscreen = mainScreenViewModel::toggleFullscreen, - onToggleInput = mainScreenViewModel::toggleInput, - modifier = Modifier.padding(paddingValues) - ) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + AnimatedVisibility( + visible = showInputState && !isFullscreen, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - if (tabState.loading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - AnimatedVisibility( - visible = !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - ChannelTabRow( - tabs = tabState.tabs, - selectedIndex = tabState.selectedIndex, - onTabSelected = { - channelTabViewModel.selectTab(it) - scope.launch { - composePagerState.animateScrollToPage(it) + Column(modifier = Modifier.fillMaxWidth()) { + ChatInputLayout( + textFieldState = chatInputViewModel.textFieldState, + inputState = inputState.inputState, + enabled = inputState.enabled, + canSend = inputState.canSend, + showReplyOverlay = inputState.showReplyOverlay, + replyName = inputState.replyName, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + onSend = chatInputViewModel::sendMessage, + onLastMessageClick = chatInputViewModel::getLastMessage, + onEmoteClick = { + if (!inputState.isEmoteMenuOpen) { + keyboardController?.hide() + chatInputViewModel.setEmoteMenuOpen(true) + } else { + keyboardController?.show() } + }, + onReplyDismiss = { + chatInputViewModel.setReplying(false) + }, + modifier = Modifier.onGloballyPositioned { coordinates -> + inputHeightPx = coordinates.size.height + inputTopY = coordinates.positionInRoot().y } ) } - HorizontalPager( - state = composePagerState, - modifier = Modifier.fillMaxSize(), - 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, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - }, - onReplyClick = { replyMessageId, replyName -> - sheetNavigationViewModel.openReplies(replyMessageId, replyName) + } + } + ) { paddingValues -> + // Main content + Box(modifier = Modifier.fillMaxSize()) { + val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() + DankBackground(visible = showFullScreenLoading) + if (showFullScreenLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + ) + return@Scaffold + } + if (tabState.tabs.isEmpty() && !tabState.loading) { + EmptyStateContent( + isLoggedIn = isLoggedIn, + onAddChannel = { showAddChannelDialog = true }, + onLogin = onLogin, + onToggleAppBar = mainScreenViewModel::toggleAppBar, + onToggleFullscreen = mainScreenViewModel::toggleFullscreen, + onToggleInput = mainScreenViewModel::toggleInput, + modifier = Modifier.padding(paddingValues) + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (tabState.loading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + AnimatedVisibility( + visible = !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + ChannelTabRow( + tabs = tabState.tabs, + selectedIndex = tabState.selectedIndex, + onTabSelected = { + channelTabViewModel.selectTab(it) + scope.launch { + composePagerState.animateScrollToPage(it) + } } ) } + HorizontalPager( + state = composePagerState, + modifier = Modifier.fillMaxSize(), + 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, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + }, + onReplyClick = { replyMessageId, replyName -> + sheetNavigationViewModel.openReplies(replyMessageId, replyName) + } + ) + } + } } } } } - } - // Fullscreen Overlay Sheets - androidx.compose.animation.AnimatedVisibility( - visible = fullScreenSheetState !is FullScreenSheetState.Closed, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = Modifier - .fillMaxSize() - .padding(bottom = sheetBottomPadding) - ) { - Box( - modifier = Modifier.fillMaxSize() + // Emote Menu Layer + // Always draw if open OR keyboard is present to be ready underneath + if (inputState.isEmoteMenuOpen || isKeyboardVisible) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(targetMenuHeight) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + } + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + EmoteMenu( + onEmoteClick = { code, _ -> + chatInputViewModel.insertText("$code ") + } + ) + } + } + + // Fullscreen Overlay Sheets + androidx.compose.animation.AnimatedVisibility( + visible = fullScreenSheetState !is FullScreenSheetState.Closed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier + .fillMaxSize() + .padding(bottom = sheetBottomPadding) ) { - when (val state = fullScreenSheetState) { - is FullScreenSheetState.Closed -> Unit - is FullScreenSheetState.Mention -> { - MentionSheet( - mentionViewModel = mentionViewModel, - initialisWhisperTab = false, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - } - ) - } - is FullScreenSheetState.Whisper -> { - MentionSheet( - mentionViewModel = mentionViewModel, - initialisWhisperTab = true, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - } - ) - } + Box( + modifier = Modifier.fillMaxSize() + ) { + when (val state = fullScreenSheetState) { + is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Mention -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = false, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + } + ) + } + is FullScreenSheetState.Whisper -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = true, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + } + ) + } - is FullScreenSheetState.Replies -> { - RepliesSheet( - rootMessageId = state.replyMessageId, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = { - sheetNavigationViewModel.closeFullScreenSheet() - chatInputViewModel.setReplying(false) - }, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - } - ) + is FullScreenSheetState.Replies -> { + RepliesSheet( + rootMessageId = state.replyMessageId, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + } + ) + } } } } - } - if (showInputState && !isFullscreen && isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp) - ) + if (showInputState && !isFullscreen && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp) + ) + } } -} } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt index 89e524600..4b05412d1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt @@ -1,37 +1,34 @@ package com.flxrs.dankchat.main.compose.dialogs -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.AlertDialog 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.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.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.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -39,99 +36,99 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.preferences.model.ChannelWithRename -import java.util.Collections +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ManageChannelsDialog( channels: List, - onRemoveChannel: (UserName) -> Unit, - onRenameChannel: (UserName, String?) -> Unit, - onReorder: (List) -> Unit, + onApplyChanges: (List) -> Unit, onDismiss: () -> Unit, ) { var channelToDelete by remember { mutableStateOf(null) } var channelToEdit by remember { mutableStateOf(null) } - // Local state for smooth reordering + // Local state for smooth reordering and deferred updates val localChannels = remember { mutableStateListOf() } - // Update local channels when external source changes LaunchedEffect(channels) { - localChannels.clear() - localChannels.addAll(channels) + if (localChannels.isEmpty() && channels.isNotEmpty()) { + localChannels.addAll(channels) + } + } + + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> + // Adjust indices because of the header item at index 0 + val fromIndex = from.index - 1 + val toIndex = to.index - 1 + + if (fromIndex in localChannels.indices && toIndex in localChannels.indices) { + localChannels.apply { + add(toIndex, removeAt(fromIndex)) + } + } } ModalBottomSheet( - onDismissRequest = onDismiss, + onDismissRequest = { + onApplyChanges(localChannels.toList()) + onDismiss() + }, ) { - Column( + LazyColumn( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp) + .padding(bottom = 32.dp), + state = lazyListState ) { - Text( - text = stringResource(R.string.manage_channels), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - ) + item { + Text( + text = stringResource(R.string.manage_channels), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(16.dp) + ) + } - LazyColumn( - modifier = Modifier.fillMaxWidth(), - state = rememberLazyListState() - ) { - itemsIndexed(localChannels, key = { _, item -> item.channel.value }) { index, channelWithRename -> - var offsetY by remember { mutableFloatStateOf(0f) } - var isDragging by remember { mutableStateOf(false) } - val itemHeight = remember { mutableIntStateOf(0) } + items(localChannels, key = { it.channel.value }) { channelWithRename -> + ReorderableItem(reorderableState, key = channelWithRename.channel.value) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) - ChannelItem( - channelWithRename = channelWithRename, - isDragging = isDragging, - offsetY = offsetY, - onGloballyPositioned = { coordinates -> - itemHeight.intValue = coordinates.size.height - }, - onDragStart = { isDragging = true }, - onDragEnd = { - isDragging = false - offsetY = 0f - onReorder(localChannels.toList()) - }, - onDrag = { change, dragAmount -> - change.consume() - offsetY += dragAmount.y - - val height = itemHeight.intValue.toFloat() - if (height > 0) { - val threshold = height * 0.5f // Swap slightly earlier than full height - if (offsetY > threshold && index < localChannels.lastIndex) { - Collections.swap(localChannels, index, index + 1) - offsetY -= height - } else if (offsetY < -threshold && index > 0) { - Collections.swap(localChannels, index, index - 1) - offsetY += height - } - } - }, - onEdit = { channelToEdit = channelWithRename }, - onDelete = { channelToDelete = channelWithRename.channel } - ) - } - - if (localChannels.isEmpty()) { - item { - Text( - text = stringResource(R.string.no_channels_added), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp) - ) + Surface( + shadowElevation = elevation, + color = if (isDragging) MaterialTheme.colorScheme.surfaceContainerHighest else Color.Transparent + ) { + Column { + ChannelItem( + channelWithRename = channelWithRename, + modifier = Modifier.longPressDraggableHandle( + onDragStarted = { /* Optional haptic feedback here */ }, + onDragStopped = { /* Optional haptic feedback here */ } + ), + onEdit = { channelToEdit = channelWithRename }, + onDelete = { channelToDelete = channelWithRename.channel } + ) + 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) + ) + } + } } } @@ -143,7 +140,10 @@ fun ManageChannelsDialog( confirmButton = { TextButton( onClick = { - channelToDelete?.let(onRemoveChannel) + val channel = channelToDelete + if (channel != null) { + localChannels.removeIf { it.channel == channel } + } channelToDelete = null } ) { @@ -161,7 +161,13 @@ fun ManageChannelsDialog( channelToEdit?.let { channel -> EditChannelDialog( channelWithRename = channel, - onRename = onRenameChannel, + onRename = { userName, newName -> + val index = localChannels.indexOfFirst { it.channel == userName } + if (index != -1) { + val rename = newName?.ifBlank { null }?.let { UserName(it) } + localChannels[index] = localChannels[index].copy(rename = rename) + } + }, onDismiss = { channelToEdit = null } ) } @@ -170,45 +176,21 @@ fun ManageChannelsDialog( @Composable private fun ChannelItem( channelWithRename: ChannelWithRename, - isDragging: Boolean, - offsetY: Float, - onGloballyPositioned: (androidx.compose.ui.layout.LayoutCoordinates) -> Unit, - onDragStart: (androidx.compose.ui.geometry.Offset) -> Unit, - onDragEnd: () -> Unit, - onDrag: (androidx.compose.ui.input.pointer.PointerInputChange, androidx.compose.ui.geometry.Offset) -> Unit, + modifier: Modifier = Modifier, // This modifier will carry the drag handle semantics onEdit: () -> Unit, onDelete: () -> Unit ) { - val density = LocalDensity.current - val elevation = if (isDragging) 8.dp else 0.dp - Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .zIndex(if (isDragging) 1f else 0f) - .graphicsLayer { - translationY = offsetY - shadowElevation = with(density) { elevation.toPx() } - } - .background(if (isDragging) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.surface) - .onGloballyPositioned(onGloballyPositioned) - .padding(horizontal = 8.dp, vertical = 4.dp), + .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 - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = onDragStart, - onDragEnd = onDragEnd, - onDragCancel = onDragEnd, - onDrag = onDrag - ) - } - .padding(8.dp) + modifier = Modifier.padding(8.dp) ) Text( @@ -222,7 +204,9 @@ private fun ChannelItem( } }, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f).padding(8.dp) + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) ) IconButton(onClick = onEdit) { @@ -239,4 +223,4 @@ private fun ChannelItem( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt new file mode 100644 index 000000000..e05746187 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt @@ -0,0 +1,158 @@ +package com.flxrs.dankchat.main.compose.sheets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.emotemenu.EmoteItem +import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab +import com.flxrs.dankchat.main.compose.EmoteMenuViewModel +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Surface +import com.flxrs.dankchat.preferences.components.DankBackground + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmoteMenu( + onEmoteClick: (String, String) -> Unit, + viewModel: EmoteMenuViewModel = koinViewModel(), + modifier: Modifier = Modifier +) { + val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { tabItems.size } + ) + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column(modifier = Modifier.fillMaxSize()) { + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + 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) + } + ) + } + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1 + ) { page -> + val tab = tabItems[page] + 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) // Offset below logo + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 48.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + 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/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 8a688ec10..6afd22266 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -3,6 +3,7 @@ package com.flxrs.dankchat.main.compose.sheets import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -80,6 +81,7 @@ fun MentionSheet( alpha = 1f - backProgress translationY = backProgress * 100f }, + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { Column { TopAppBar( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index f06cdf452..e0e6d4cd5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.main.compose.sheets import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -13,6 +14,7 @@ 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.mutableFloatStateOf import androidx.compose.runtime.remember @@ -68,6 +70,7 @@ fun RepliesSheet( } ) }, + contentWindowInsets = WindowInsets(0, 0, 0, 0), modifier = Modifier .fillMaxSize() .graphicsLayer { 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 5172df84d..ab4d719b5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -81,6 +81,14 @@ 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) } @@ -218,6 +226,8 @@ class DankChatPreferenceStore( 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" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 596550ca3..330a42760 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,8 @@ FeelsDankMan DankChat running in the background Open the emote menu + Close the emote menu + No recent emotes Emotes Login to Twitch.tv Start chatting diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b37c9176..b94dcd0af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ autoLinkText = "2.0.2" processPhoenix = "3.0.0" colorPicker = "3.1.0" +reorderable = "2.4.3" junit = "6.0.1" mockk = "1.14.7" @@ -50,6 +51,8 @@ android-desugar-libs = { module = "com.android.tools:desugar_jdk_libs", version. android-material = { module = "com.google.android.material:material", version.ref = "material" } android-flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexBox" } +reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } + 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" } From b8c4851878d9be9c4f667da4a20a5594b1e75ea5 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 013/349] feat(compose): Add stream view, AppBar menus, and room state dialog --- .../dankchat/chat/compose/ChatComposable.kt | 15 +- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 117 +++- .../chat/user/compose/UserPopupDialog.kt | 4 + .../data/repo/stream/StreamDataRepository.kt | 69 +++ .../dankchat/domain/ChannelDataLoader.kt | 5 +- .../dankchat/main/compose/ChatInputLayout.kt | 415 +++++++++---- .../main/compose/ChatInputViewModel.kt | 71 ++- .../main/compose/EmptyStateContent.kt | 14 +- .../flxrs/dankchat/main/compose/MainAppBar.kt | 280 ++++++++- .../flxrs/dankchat/main/compose/MainScreen.kt | 577 +++++++++++++++--- .../flxrs/dankchat/main/compose/StreamView.kt | 162 +++++ .../dankchat/main/compose/StreamViewModel.kt | 77 +++ .../main/compose/dialogs/RoomStateDialog.kt | 190 ++++++ .../dankchat/main/compose/sheets/EmoteMenu.kt | 11 +- app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 15 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 26 + app/src/main/res/values-tr-rTR/strings.xml | 15 + app/src/main/res/values/strings.xml | 11 + gradle/libs.versions.toml | 24 +- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +- gradlew.bat | 3 +- 26 files changed, 1832 insertions(+), 283 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 1b61d51eb..625782cda 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.chat.compose +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -31,6 +32,12 @@ fun ChatComposable( onEmoteClick: (List) -> Unit, onReplyClick: (String, UserName) -> Unit, modifier: Modifier = Modifier, + showInput: Boolean = true, + isFullscreen: Boolean = false, + hasHelperText: Boolean = false, + onRecover: () -> Unit = {}, + contentPadding: PaddingValues = PaddingValues(), + onScrollDirectionChanged: (Boolean) -> Unit = {}, ) { // Create ChatComposeViewModel with channel-specific key for proper scoping val viewModel: ChatComposeViewModel = koinViewModel( @@ -54,6 +61,12 @@ fun ChatComposable( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick + onReplyClick = onReplyClick, + showInput = showInput, + isFullscreen = isFullscreen, + hasHelperText = hasHelperText, + onRecover = onRecover, + contentPadding = contentPadding, + onScrollDirectionChanged = onScrollDirectionChanged ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 6c3202fa0..e80be1a97 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -1,17 +1,32 @@ package com.flxrs.dankchat.chat.compose +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.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsBottomHeight 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.FullscreenExit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButton 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -19,12 +34,13 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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.res.stringResource import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.messages.ModerationMessageComposable import com.flxrs.dankchat.chat.compose.messages.NoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.PointRedemptionMessageComposable @@ -56,15 +72,19 @@ fun ChatScreen( animateGifs: Boolean = true, onEmoteClick: (emotes: List) -> Unit = {}, onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit = { _, _ -> }, + showInput: Boolean = true, + isFullscreen: Boolean = false, + hasHelperText: Boolean = false, + onRecover: () -> Unit = {}, + contentPadding: PaddingValues = PaddingValues(), + onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, ) { val listState = rememberLazyListState() - val scope = rememberCoroutineScope() // Track if we should auto-scroll to bottom (sticky state) - // Use rememberSaveable to survive configuration changes (like theme switches) var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } - // Detect if we're showing the newest messages + // Detect if we're showing the newest messages (with reverseLayout, index 0 = newest) val isAtBottom by remember { derivedStateOf { val firstVisibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() @@ -72,11 +92,12 @@ fun ChatScreen( } } - // Disable auto-scroll when user scrolls forward (up in the chat) + // Disable auto-scroll when user scrolls forward (up in chat) LaunchedEffect(listState.isScrollInProgress) { if (listState.lastScrolledForward && shouldAutoScroll) { shouldAutoScroll = false } + onScrollDirectionChanged(listState.lastScrolledForward) } // Auto-scroll when new messages arrive or when re-enabled @@ -94,8 +115,15 @@ fun ChatScreen( LazyColumn( state = listState, reverseLayout = true, + contentPadding = contentPadding, modifier = Modifier.fillMaxSize() ) { + if (!showInput && !hasHelperText) { + item(key = "nav-bar-spacer") { + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + } + } + items( items = messages.asReversed(), key = { message -> "${message.id}-${message.tag}" }, @@ -129,26 +157,79 @@ fun ChatScreen( } } - // Scroll to bottom FAB (show when not at bottom) - if (!isAtBottom && messages.isNotEmpty()) { - FloatingActionButton( - onClick = { - shouldAutoScroll = true - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp) + // FABs at bottom-end with coordinated position animation + val showScrollFab = !isAtBottom && messages.isNotEmpty() + val fabBottomPadding by animateDpAsState( + targetValue = if (!showInput) 24.dp else 0.dp, + animationSpec = if (showInput) snap() else spring(), + label = "fabBottomPadding" + ) + val recoveryBottomPadding by animateDpAsState( + targetValue = if (showScrollFab) 56.dp + 12.dp else 0.dp, + label = "recoveryBottomPadding" + ) + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + fabBottomPadding), + contentAlignment = Alignment.BottomEnd + ) { + RecoveryFab( + isFullscreen = isFullscreen, + showInput = showInput, + onRecover = onRecover, + modifier = Modifier.padding(bottom = recoveryBottomPadding) + ) + AnimatedVisibility( + visible = showScrollFab, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), ) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to bottom" - ) + FloatingActionButton( + onClick = { + shouldAutoScroll = true + onScrollDirectionChanged(false) + }, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom" + ) + } } } } } } +@Composable +private fun RecoveryFab( + isFullscreen: Boolean, + showInput: Boolean, + onRecover: () -> Unit, + modifier: Modifier = Modifier +) { + val visible = isFullscreen || !showInput + AnimatedVisibility( + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = modifier + ) { + SmallFloatingActionButton( + onClick = onRecover, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = stringResource(R.string.menu_exit_fullscreen) + ) + } + } +} + /** * Renders a single chat message based on its type */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index 06cd17e2c..e887341b8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.PlainTooltip import androidx.compose.material3.RichTooltip import androidx.compose.material3.Text +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults @@ -253,6 +254,9 @@ private fun UserPopupButton( TextButton( onClick = onClick, modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) ) { Row( modifier = Modifier.fillMaxWidth(), 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..45aef8c1b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt @@ -0,0 +1,69 @@ +package com.flxrs.dankchat.data.repo.stream + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.main.StreamData +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.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.time.Duration.Companion.seconds + +@Single +class StreamDataRepository( + private val dataRepository: DataRepository, + 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>(emptyList()) + val streamData: StateFlow> = _streamData.asStateFlow() + + fun fetchStreamData(channels: List) { + cancelStreamData() + channels.ifEmpty { return } + + scope.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() + } + + companion object { + private val STREAM_REFRESH_RATE = 30.seconds + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index 6aff6637d..dccfbd1ec 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -24,6 +24,7 @@ class ChannelDataLoader( private val dataRepository: DataRepository, private val chatRepository: ChatRepository, private val channelRepository: ChannelRepository, + private val getChannelsUseCase: GetChannelsUseCase, private val dispatchersProvider: DispatchersProvider ) { @@ -35,8 +36,10 @@ class ChannelDataLoader( emit(ChannelLoadingState.Loading) try { - // Get channel info + // Get channel info - uses GetChannelsUseCase which waits for IRC ROOMSTATE + // if not logged in, matching the legacy MainViewModel.loadData behavior val channelInfo = channelRepository.getChannel(channel) + ?: getChannelsUseCase(listOf(channel)).firstOrNull() if (channelInfo == null) { emit(ChannelLoadingState.Failed("Channel not found", emptyList())) return@flow diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 3e2088395..1032873fd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,36 +1,42 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +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.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation -import androidx.compose.foundation.indication -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.animation.slideInVertically 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.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions +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.Repeat +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.MoreVert +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.VisibilityOff +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,29 +45,29 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +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.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.material.icons.filled.Keyboard @Composable fun ChatInputLayout( @@ -72,10 +78,19 @@ fun ChatInputLayout( showReplyOverlay: Boolean, replyName: UserName?, isEmoteMenuOpen: Boolean, + helperText: String?, + isFullscreen: Boolean, + isModerator: Boolean, + isStreamActive: Boolean, + hasStreamData: Boolean, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, onReplyDismiss: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + onToggleStream: () -> Unit, + onChangeRoomState: () -> Unit, modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } @@ -86,133 +101,285 @@ fun ChatInputLayout( InputState.Disconnected -> stringResource(R.string.hint_disconnected) } - Surface( - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() + 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 + } + + var quickActionsExpanded by remember { androidx.compose.runtime.mutableStateOf(false) } + val topEndRadius by androidx.compose.animation.core.animateDpAsState( + targetValue = if (quickActionsExpanded) 0.dp else 24.dp, + label = "topEndCornerRadius" + ) + + Box(modifier = modifier.fillMaxWidth()) { + Surface( + shape = RoundedCornerShape(topStart = 24.dp, topEnd = topEndRadius), + color = surfaceColor, + modifier = Modifier.fillMaxWidth() ) { - // Reply Header - AnimatedVisibility( - visible = showReplyOverlay && replyName != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() ) { - 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 = stringResource(R.string.reply_header, replyName?.value.orEmpty()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - IconButton( - onClick = onReplyDismiss, - modifier = Modifier.size(24.dp) + // Reply Header + AnimatedVisibility( + visible = showReplyOverlay && replyName != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp) ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.dialog_dismiss), - modifier = Modifier.size(16.dp) + Text( + text = stringResource(R.string.reply_header, replyName?.value.orEmpty()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold ) + IconButton( + onClick = onReplyDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + modifier = Modifier.size(16.dp) + ) + } } + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(horizontal = 16.dp) + ) } - HorizontalDivider( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - modifier = Modifier.padding(horizontal = 16.dp) + } + + // Text Field + TextField( + state = textFieldState, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .padding(bottom = 0.dp), // Reduce bottom padding as actions are below + label = { Text(hint) }, + colors = textFieldColors, + shape = RoundedCornerShape(0.dp), + lineLimits = TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = 5 + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + onKeyboardAction = { if (canSend) onSend() } + ) + + // Helper text (roomstate + live info) + AnimatedVisibility( + visible = !helperText.isNullOrEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Text( + text = helperText.orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 4.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Start ) } - } - // Text Field - TextField( - state = textFieldState, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .padding(bottom = 0.dp), // Reduce bottom padding as actions are below - label = { Text(hint) }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent - ), - shape = RoundedCornerShape(0.dp), - lineLimits = TextFieldLineLimits.MultiLine( - minHeightInLines = 1, - maxHeightInLines = 5 - ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - onKeyboardAction = { if (canSend) onSend() } - ) - - // Actions Row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) - ) { - // Emote/Keyboard Button (Left) - IconButton( - onClick = { + // Actions Row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + ) { + // Emote/Keyboard Button (Left) + IconButton( + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() + } + onEmoteClick() + }, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { if (isEmoteMenuOpen) { - focusRequester.requestFocus() + Icon( + imageVector = Icons.Default.Keyboard, + contentDescription = stringResource(R.string.dialog_dismiss), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = stringResource(R.string.emote_menu_hint), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } - onEmoteClick() - }, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - if (isEmoteMenuOpen) { + } + + Spacer(modifier = Modifier.weight(1f)) + + // Quick Actions Button + IconButton( + onClick = { quickActionsExpanded = !quickActionsExpanded }, + modifier = Modifier.size(40.dp) + ) { Icon( - imageVector = Icons.Default.Keyboard, - contentDescription = stringResource(R.string.dialog_dismiss), + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), tint = MaterialTheme.colorScheme.onSurfaceVariant ) - } else { + } + + // History Button (Always visible) + IconButton( + onClick = onLastMessageClick, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { Icon( - imageVector = Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.emote_menu_hint), + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.resume_scroll), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } + + // Send Button (Right) + SendButton( + enabled = canSend, + onSend = onSend, + modifier = Modifier + ) } + } + } - Spacer(modifier = Modifier.weight(1f)) + // Quick actions menu — Popup with custom positioning and slide animation + val menuVisibleState = remember { MutableTransitionState(false) } + menuVisibleState.targetState = quickActionsExpanded - // History Button (Always visible) - IconButton( - onClick = onLastMessageClick, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.History, - contentDescription = stringResource(R.string.resume_scroll), // Using resume_scroll as a placeholder for "History/Last message" context - tint = MaterialTheme.colorScheme.onSurfaceVariant + if (menuVisibleState.currentState || menuVisibleState.targetState) { + val positionProvider = remember { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset = IntOffset( + x = anchorBounds.right - popupContentSize.width, + y = anchorBounds.top - popupContentSize.height ) } + } - // Send Button (Right) - SendButton( - enabled = canSend, - onSend = onSend, - modifier = Modifier - ) + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = { quickActionsExpanded = false }, + properties = PopupProperties(focusable = true), + ) { + AnimatedVisibility( + visibleState = menuVisibleState, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 150) + ) + fadeIn(animationSpec = tween(durationMillis = 100)), + exit = shrinkVertically( + shrinkTowards = Alignment.Bottom, + animationSpec = tween(durationMillis = 120) + ) + fadeOut(animationSpec = tween(durationMillis = 80)), + ) { + Surface( + shape = RoundedCornerShape(topStart = 12.dp), + color = surfaceColor, + ) { + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + if (hasStreamData || isStreamActive) { + DropdownMenuItem( + text = { Text(stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) }, + onClick = { + onToggleStream() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + contentDescription = null + ) + } + ) + } + DropdownMenuItem( + text = { Text(stringResource(if (isFullscreen) R.string.menu_exit_fullscreen else R.string.menu_fullscreen)) }, + onClick = { + onToggleFullscreen() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_hide_input)) }, + onClick = { + onToggleInput() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = null + ) + } + ) + if (isModerator) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_room_state)) }, + onClick = { + onChangeRoomState() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null + ) + } + ) + } + } + } + } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 970df18f1..ee96a74d3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -13,6 +13,7 @@ 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.stream.StreamDataRepository import com.flxrs.dankchat.data.repo.command.CommandResult import com.flxrs.dankchat.data.twitch.chat.ConnectionState import com.flxrs.dankchat.data.twitch.command.TwitchCommand @@ -21,7 +22,9 @@ import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.main.RepeatedSendData import com.flxrs.dankchat.main.compose.FullScreenSheetState 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.stream.StreamsSettingsDataStore import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -32,8 +35,10 @@ 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 @@ -49,7 +54,10 @@ class ChatInputViewModel( private val userStateRepository: UserStateRepository, private val suggestionProvider: SuggestionProvider, private val preferenceStore: DankChatPreferenceStore, + private val chatSettingsDataStore: ChatSettingsDataStore, private val developerSettingsDataStore: DeveloperSettingsDataStore, + private val streamDataRepository: StreamDataRepository, + private val streamsSettingsDataStore: StreamsSettingsDataStore, private val mainEventBus: MainEventBus, ) : ViewModel() { @@ -82,6 +90,44 @@ class ChatInputViewModel( suggestionProvider.getSuggestions(text, channel) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private val roomStateDisplayText: StateFlow = combine( + chatSettingsDataStore.showChatModes, + chatRepository.activeChannel + ) { showModes, channel -> + showModes to channel + }.flatMapLatest { (showModes, channel) -> + if (!showModes || channel == null) flowOf(null) + else channelRepository.getRoomStateFlow(channel).map { it.toDisplayText().ifEmpty { null } } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val currentStreamInfo: StateFlow = combine( + streamsSettingsDataStore.showStreamsInfo, + chatRepository.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( + roomStateDisplayText, + currentStreamInfo + ) { roomState, streamInfo -> + listOfNotNull(roomState, streamInfo) + .joinToString(separator = " - ") + .ifEmpty { null } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val hasStreamData: StateFlow = combine( + chatRepository.activeChannel, + streamDataRepository.streamData + ) { activeChannel, streamData -> + activeChannel != null && streamData.any { it.channel == activeChannel } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + private var _uiState: StateFlow? = null init { @@ -103,6 +149,20 @@ class ChatInputViewModel( } } } + + // Trigger stream data fetching whenever channels change + viewModelScope.launch { + chatRepository.channels.collect { channels -> + if (channels != null) { + streamDataRepository.fetchStreamData(channels) + } + } + } + } + + override fun onCleared() { + super.onCleared() + streamDataRepository.cancelStreamData() } private data class UiDependencies( @@ -158,8 +218,9 @@ class ChatInputViewModel( _uiState = combine( baseFlow, - sheetAndReplyFlow - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen) -> + sheetAndReplyFlow, + helperText + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen), helperText -> this.fullScreenSheetState.value = sheetState this.mentionSheetTab.value = tab @@ -194,7 +255,8 @@ class ChatInputViewModel( showReplyOverlay = showReplyOverlay, replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, replyName = effectiveReplyName, - isEmoteMenuOpen = isEmoteMenuOpen + isEmoteMenuOpen = isEmoteMenuOpen, + helperText = helperText ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) @@ -352,5 +414,6 @@ data class ChatInputUiState( val showReplyOverlay: Boolean = false, val replyMessageId: String? = null, val replyName: UserName? = null, - val isEmoteMenuOpen: Boolean = false + val isEmoteMenuOpen: Boolean = false, + val helperText: String? = null ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt index 043983b59..5b838c8f9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt @@ -30,8 +30,6 @@ fun EmptyStateContent( onAddChannel: () -> Unit, onLogin: () -> Unit, onToggleAppBar: () -> Unit, - onToggleFullscreen: () -> Unit, - onToggleInput: () -> Unit, modifier: Modifier = Modifier ) { Surface(modifier = modifier) { @@ -70,17 +68,7 @@ fun EmptyStateContent( AssistChip( onClick = onToggleAppBar, - label = { Text("Toggle App Bar") } // Consider using resources - ) - - AssistChip( - onClick = onToggleFullscreen, - label = { Text("Toggle Fullscreen") } - ) - - AssistChip( - onClick = onToggleInput, - label = { Text("Toggle Input") } + label = { Text("Toggle App Bar") } ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index fbbb3935d..e27e79545 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -7,9 +7,16 @@ 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.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.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications @@ -28,10 +35,14 @@ 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.Shape import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R -private sealed interface AppBarMenu { +sealed interface AppBarMenu { data object Main : AppBarMenu data object Account : AppBarMenu data object Channel : AppBarMenu @@ -239,7 +250,7 @@ fun MainAppBar( } ) } - + null -> {} } } @@ -250,15 +261,149 @@ fun MainAppBar( ) } +@Composable +fun ToolbarOverflowMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isLoggedIn: Boolean, + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onOpenSettings: () -> Unit, + shape: Shape = MaterialTheme.shapes.medium, + offset: DpOffset = DpOffset.Zero, +) { + var currentMenu by remember { mutableStateOf(AppBarMenu.Main) } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { + onDismiss() + currentMenu = AppBarMenu.Main + }, + shape = shape, + offset = offset + ) { + 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 = "MenuTransition" + ) { menu -> + Column { + when (menu) { + AppBarMenu.Main -> { + if (!isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.login)) }, + onClick = { onLogin(); onDismiss() } + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.account)) }, + onClick = { currentMenu = AppBarMenu.Account } + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_channels)) }, + onClick = { onManageChannels(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.channel)) }, + onClick = { currentMenu = AppBarMenu.Channel } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.upload_media)) }, + onClick = { currentMenu = AppBarMenu.Upload } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.more)) }, + onClick = { currentMenu = AppBarMenu.More } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.settings)) }, + onClick = { onOpenSettings(); onDismiss() } + ) + } + AppBarMenu.Account -> { + SubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.relogin)) }, + onClick = { onRelogin(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.logout)) }, + onClick = { onLogout(); onDismiss() } + ) + } + AppBarMenu.Channel -> { + SubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.open_channel)) }, + onClick = { onOpenChannel(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_channel)) }, + onClick = { onRemoveChannel(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_channel)) }, + onClick = { onReportChannel(); onDismiss() } + ) + if (isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_channel)) }, + onClick = { onBlockChannel(); onDismiss() } + ) + } + } + AppBarMenu.Upload -> { + SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + } + AppBarMenu.More -> { + SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.reload_emotes)) }, + onClick = { onReloadEmotes(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.reconnect)) }, + onClick = { onReconnect(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.clear_chat)) }, + onClick = { onClearChat(); onDismiss() } + ) + } + null -> {} + } + } + } + } +} + @Composable private fun SubMenuHeader(title: String, onBack: () -> Unit) { DropdownMenuItem( - text = { + text = { Text( text = title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) + ) }, leadingIcon = { Icon( @@ -269,4 +414,131 @@ private fun SubMenuHeader(title: String, onBack: () -> Unit) { }, onClick = onBack ) +} + +@Composable +fun InlineOverflowMenu( + isLoggedIn: Boolean, + isStreamActive: Boolean = false, + hasStreamData: Boolean = false, + onDismiss: () -> Unit, + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onToggleStream: () -> Unit = {}, + onOpenSettings: () -> Unit, + initialMenu: AppBarMenu = AppBarMenu.Main, +) { + var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } + + 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 { + when (menu) { + AppBarMenu.Main -> { + if (!isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.login)) { onLogin(); onDismiss() } + } else { + InlineMenuItem(text = stringResource(R.string.account), hasSubMenu = true) { currentMenu = AppBarMenu.Account } + } + InlineMenuItem(text = stringResource(R.string.manage_channels)) { onManageChannels(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.channel), hasSubMenu = true) { currentMenu = AppBarMenu.Channel } + InlineMenuItem(text = stringResource(R.string.upload_media), hasSubMenu = true) { currentMenu = AppBarMenu.Upload } + InlineMenuItem(text = stringResource(R.string.more), hasSubMenu = true) { currentMenu = AppBarMenu.More } + InlineMenuItem(text = stringResource(R.string.settings)) { onOpenSettings(); onDismiss() } + } + AppBarMenu.Account -> { + InlineSubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.relogin)) { onRelogin(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.logout)) { onLogout(); onDismiss() } + } + AppBarMenu.Channel -> { + InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) + if (hasStreamData || isStreamActive) { + InlineMenuItem(text = stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) { onToggleStream(); onDismiss() } + } + InlineMenuItem(text = stringResource(R.string.open_channel)) { onOpenChannel(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.remove_channel)) { onRemoveChannel(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.report_channel)) { onReportChannel(); onDismiss() } + if (isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.block_channel)) { onBlockChannel(); onDismiss() } + } + } + AppBarMenu.Upload -> { + InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + } + AppBarMenu.More -> { + InlineSubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.reload_emotes)) { onReloadEmotes(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reconnect)) { onReconnect(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.clear_chat)) { onClearChat(); onDismiss() } + } + } + } + } +} + +@Composable +private fun InlineMenuItem(text: String, hasSubMenu: Boolean = false, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + if (hasSubMenu) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun InlineSubMenuHeader(title: String, onBack: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onBack) + .padding(horizontal = 12.dp, vertical = 12.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 + ) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 4e857ae93..80b03665e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -1,6 +1,20 @@ package com.flxrs.dankchat.main.compose import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.DpOffset import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.statusBars @@ -11,13 +25,27 @@ import android.content.ClipData import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Brush +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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 @@ -31,7 +59,15 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingToolbarDefaults +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -50,6 +86,7 @@ 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.drawWithContent import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot @@ -84,10 +121,13 @@ import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.main.compose.FullScreenSheetState import com.flxrs.dankchat.main.compose.InputSheetState import com.flxrs.dankchat.main.MainEvent +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog import com.flxrs.dankchat.main.compose.sheets.EmoteMenuSheet import com.flxrs.dankchat.main.compose.sheets.MentionSheet import com.flxrs.dankchat.main.compose.sheets.RepliesSheet @@ -105,6 +145,9 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.ui.input.pointer.pointerInput @@ -113,10 +156,13 @@ import androidx.compose.ui.unit.sp import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun MainScreen( navController: NavController, @@ -145,6 +191,7 @@ fun MainScreen( val channelPagerViewModel: ChannelPagerViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val streamViewModel: StreamViewModel = koinViewModel() val mentionViewModel: com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel = koinViewModel() val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() val developerSettingsDataStore: DeveloperSettingsDataStore = koinInject() @@ -195,8 +242,14 @@ fun MainScreen( } } + // 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) } } @@ -205,8 +258,19 @@ fun MainScreen( val isKeyboardVisible = isImeVisible || isImeOpening var backProgress by remember { mutableStateOf(0f) } - // Disable if Keyboard is open or opening to prevent conflict - PredictiveBackHandler(enabled = inputState.isEmoteMenuOpen && !isKeyboardVisible) { progress -> + // Stream state + val currentStream by streamViewModel.currentStreamedChannel.collectAsStateWithLifecycle() + val hasStreamData by chatInputViewModel.hasStreamData.collectAsStateWithLifecycle() + var streamHeightDp by remember { mutableStateOf(0.dp) } + LaunchedEffect(currentStream) { + if (currentStream == null) streamHeightDp = 0.dp + } + + + + // 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 @@ -224,9 +288,15 @@ fun MainScreen( var showRemoveChannelDialog by remember { mutableStateOf(false) } var showBlockChannelDialog by remember { mutableStateOf(false) } var showClearChatDialog by remember { mutableStateOf(false) } + var showOverflowMenu by remember { mutableStateOf(false) } + var overflowInitialMenu by remember { mutableStateOf(AppBarMenu.Main) } var userPopupParams by remember { mutableStateOf(null) } var messageOptionsParams by remember { mutableStateOf(null) } var emoteInfoEmotes by remember { mutableStateOf?>(null) } + var showRoomStateDialog by remember { mutableStateOf(false) } + + val channelRepository: ChannelRepository = koinInject() + val userStateRepository: UserStateRepository = koinInject() val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() @@ -319,6 +389,17 @@ fun MainScreen( ) } + val roomStateChannel = inputState.activeChannel + if (showRoomStateDialog && roomStateChannel != null) { + RoomStateDialog( + roomState = channelRepository.getRoomState(roomStateChannel), + onSendCommand = { command -> + chatInputViewModel.trySendMessageOrCommand(command) + }, + onDismiss = { showRoomStateDialog = false } + ) + } + if (showRemoveChannelDialog && activeChannel != null) { AlertDialog( onDismissRequest = { showRemoveChannelDialog = false }, @@ -506,15 +587,18 @@ fun MainScreen( val inputHeightDp = with(density) { inputHeightPx.toDp() } val sheetBottomPadding = with(density) { (containerHeight - inputTopY).toDp() } - // Track keyboard visibility - clear focus only when keyboard is fully closed + // Clear focus when keyboard fully reaches the bottom, but not when + // switching to the emote menu. Prevents keyboard from reopening when + // returning from background. val focusManager = LocalFocusManager.current - val imeAnimationTarget = WindowInsets.imeAnimationTarget - val isKeyboardAtBottom = imeAnimationTarget.getBottom(density) == 0 - - LaunchedEffect(isKeyboardAtBottom) { - if (isKeyboardAtBottom) { - focusManager.clearFocus() - } + LaunchedEffect(Unit) { + snapshotFlow { imeHeightState.value == 0 && !inputState.isEmoteMenuOpen } + .distinctUntilChanged() + .collect { shouldClearFocus -> + if (shouldClearFocus) { + focusManager.clearFocus() + } + } } // Sync Compose pager with ViewModel state @@ -534,77 +618,36 @@ fun MainScreen( } } - val systemBarsPaddingModifier = if (isFullscreen) Modifier else Modifier.statusBarsPadding() - Box(modifier = Modifier .fillMaxSize() .onGloballyPositioned { containerHeight = it.size.height } ) { - val currentImeHeightDp = with(density) { currentImeHeight.toDp() } - val targetImeHeightDp = with(density) { targetImeHeight.toDp() } - + // 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 totalMenuHeight = targetMenuHeight + navBarHeightDp - val scaffoldBottomPadding = max( - targetImeHeightDp, - max(currentImeHeightDp, if (inputState.isEmoteMenuOpen) targetMenuHeight else 0.dp) - ) + val currentImeDp = with(density) { currentImeHeight.toDp() } + val emoteMenuPadding = if (inputState.isEmoteMenuOpen) targetMenuHeight else 0.dp + val scaffoldBottomPadding = max(currentImeDp, emoteMenuPadding) Scaffold( modifier = modifier .fillMaxSize() - .then(systemBarsPaddingModifier) .padding(bottom = scaffoldBottomPadding), - contentWindowInsets = WindowInsets.statusBars, - topBar = { - if (tabState.tabs.isEmpty()) { - return@Scaffold - } - - AnimatedVisibility( - visible = showAppBar && !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - MainAppBar( - isLoggedIn = isLoggedIn, - totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onAddChannel = { showAddChannelDialog = true }, - onOpenMentions = { sheetNavigationViewModel.openMentions() }, - onOpenWhispers = { sheetNavigationViewModel.openWhispers() }, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = { showLogoutDialog = true }, - onManageChannels = { showManageChannelsDialog = true }, - onOpenChannel = onOpenChannel, - onRemoveChannel = { showRemoveChannelDialog = true }, - onReportChannel = onReportChannel, - onBlockChannel = { showBlockChannelDialog = true }, - onReloadEmotes = { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() - }, - onReconnect = { - channelManagementViewModel.reconnect() - onReconnect() - }, - onClearChat = { showClearChatDialog = true }, - onOpenSettings = onNavigateToSettings - ) - } - }, + contentWindowInsets = WindowInsets(0), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, bottomBar = { - AnimatedVisibility( - visible = showInputState && !isFullscreen, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + if (showInputState) { ChatInputLayout( textFieldState = chatInputViewModel.textFieldState, inputState = inputState.inputState, @@ -613,6 +656,11 @@ fun MainScreen( showReplyOverlay = inputState.showReplyOverlay, replyName = inputState.replyName, isEmoteMenuOpen = inputState.isEmoteMenuOpen, + helperText = inputState.helperText, + isFullscreen = isFullscreen, + isModerator = userStateRepository.isModeratorInChannel(inputState.activeChannel), + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, onSend = chatInputViewModel::sendMessage, onLastMessageClick = chatInputViewModel::getLastMessage, onEmoteClick = { @@ -626,12 +674,42 @@ fun MainScreen( onReplyDismiss = { chatInputViewModel.setReplying(false) }, + onToggleFullscreen = mainScreenViewModel::toggleFullscreen, + onToggleInput = mainScreenViewModel::toggleInput, + onToggleStream = { + activeChannel?.let { streamViewModel.toggleStream(it) } + }, + onChangeRoomState = { showRoomStateDialog = true }, modifier = Modifier.onGloballyPositioned { coordinates -> inputHeightPx = coordinates.size.height inputTopY = coordinates.positionInRoot().y } ) } + + // Sticky helper text + nav bar spacer when input is hidden + if (!showInputState) { + val helperText = inputState.helperText + if (!helperText.isNullOrEmpty()) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = helperText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Start + ) + } + } + } } } ) { paddingValues -> @@ -653,8 +731,6 @@ fun MainScreen( onAddChannel = { showAddChannelDialog = true }, onLogin = onLogin, onToggleAppBar = mainScreenViewModel::toggleAppBar, - onToggleFullscreen = mainScreenViewModel::toggleFullscreen, - onToggleInput = mainScreenViewModel::toggleInput, modifier = Modifier.padding(paddingValues) ) } else { @@ -663,25 +739,6 @@ fun MainScreen( .fillMaxSize() .padding(paddingValues) ) { - if (tabState.loading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - AnimatedVisibility( - visible = !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - ChannelTabRow( - tabs = tabState.tabs, - selectedIndex = tabState.selectedIndex, - onTabSelected = { - channelTabViewModel.selectTab(it) - scope.launch { - composePagerState.animateScrollToPage(it) - } - } - ) - } HorizontalPager( state = composePagerState, modifier = Modifier.fillMaxSize(), @@ -715,7 +772,18 @@ fun MainScreen( }, onReplyClick = { replyMessageId, replyName -> sheetNavigationViewModel.openReplies(replyMessageId, replyName) - } + }, + showInput = showInputState, + isFullscreen = isFullscreen, + hasHelperText = !inputState.helperText.isNullOrEmpty(), + onRecover = { + if (isFullscreen) mainScreenViewModel.toggleFullscreen() + if (!showInputState) mainScreenViewModel.toggleInput() + }, + contentPadding = PaddingValues( + top = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamHeightDp) + 56.dp + ), + onScrollDirectionChanged = { } ) } } @@ -724,14 +792,329 @@ fun MainScreen( } } - // Emote Menu Layer - // Always draw if open OR keyboard is present to be ready underneath - if (inputState.isEmoteMenuOpen || isKeyboardVisible) { + // Stream View layer + currentStream?.let { channel -> + StreamView( + channel = channel, + streamViewModel = streamViewModel, + onClose = { + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + streamHeightDp = with(density) { coordinates.size.height.toDp() } + } + ) + } + + // Status bar scrim + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .background(if (currentStream != null) Color.Black else MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) + ) + + // Floating Toolbars - collapsible tabs (expand on swipe) + actions + if (tabState.tabs.isNotEmpty()) { + var isTabsExpanded by remember { mutableStateOf(false) } + + val maxVisibleTabs = 3 + val totalTabs = tabState.tabs.size + val hasOverflow = totalTabs > maxVisibleTabs + val selectedIndex = tabState.selectedIndex + val visibleStartIndex = when { + !hasOverflow -> 0 + selectedIndex <= 0 -> 0 + selectedIndex >= totalTabs - 1 -> maxOf(0, totalTabs - maxVisibleTabs) + else -> selectedIndex - 1 + } + val visibleEndIndex = minOf(visibleStartIndex + maxVisibleTabs, totalTabs) + + // Expand tabs when pager is swiped in a direction with more channels + LaunchedEffect(composePagerState.isScrollInProgress) { + if (composePagerState.isScrollInProgress && hasOverflow) { + // Wait for swipe direction to establish + val offset = snapshotFlow { composePagerState.currentPageOffsetFraction } + .first { it != 0f } + val current = composePagerState.currentPage + val swipingForward = offset > 0 // towards higher index + val swipingBackward = offset < 0 // towards lower index + if ((swipingForward && current < totalTabs - 1) || (swipingBackward && current > 0)) { + isTabsExpanded = true + } + } + } + + // Auto-collapse after scroll stops + 2s delay + LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress) { + if (isTabsExpanded && !composePagerState.isScrollInProgress) { + delay(2000) + isTabsExpanded = false + } + } + + // Dismiss scrim for inline overflow menu + if (showOverflowMenu) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + } + ) + } + + AnimatedVisibility( + visible = showAppBar && !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(top = if (currentStream != null) streamHeightDp else with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .padding(top = 8.dp) + ) { + val tabListState = rememberLazyListState() + + // Auto-scroll to keep selected tab visible + LaunchedEffect(selectedIndex) { + tabListState.animateScrollToItem(selectedIndex) + } + + // Mention indicators based on visibility + val visibleItems = tabListState.layoutInfo.visibleItemsInfo + val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 + val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) + val hasLeftMention = tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } + val hasRightMention = tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.Top + ) { + // Scrollable tabs pill + Surface( + modifier = Modifier.weight(1f, fill = false), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + val mentionGradientColor = MaterialTheme.colorScheme.error + LazyRow( + state = tabListState, + contentPadding = PaddingValues(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 8.dp) + .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 = androidx.compose.ui.geometry.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 = androidx.compose.ui.geometry.Offset(size.width - gradientWidth, 0f), + size = androidx.compose.ui.geometry.Size(gradientWidth, size.height) + ) + } + } + ) { + itemsIndexed( + items = tabState.tabs, + key = { _, tab -> tab.channel.value } + ) { index, tab -> + val isSelected = tab.isSelected + val textColor = when { + isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .combinedClickable( + onClick = { + channelTabViewModel.selectTab(index) + scope.launch { composePagerState.scrollToPage(index) } + }, + onLongClick = { + channelTabViewModel.selectTab(index) + scope.launch { composePagerState.scrollToPage(index) } + overflowInitialMenu = AppBarMenu.Channel + showOverflowMenu = true + } + ) + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 12.dp) + ) { + Text( + text = tab.displayName, + color = textColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(4.dp)) + Badge() + } + } + } + } + } + + // Action icons + inline overflow menu (animated with expand/collapse) + AnimatedVisibility( + visible = !isTabsExpanded, + enter = expandHorizontally( + expandFrom = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeIn(tween(200)), + exit = shrinkHorizontally( + shrinkTowards = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeOut(tween(150)) + ) { + Row(verticalAlignment = Alignment.Top) { + Spacer(Modifier.width(8.dp)) + + val pillCornerRadius by animateDpAsState( + targetValue = if (showOverflowMenu) 0.dp else 28.dp, + animationSpec = tween(200), + label = "pillCorner" + ) + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Surface( + shape = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = pillCornerRadius, + bottomEnd = pillCornerRadius + ), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { showAddChannelDialog = true }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_channel) + ) + } + IconButton(onClick = { sheetNavigationViewModel.openMentions() }) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.mentions_title), + tint = if (tabState.tabs.sumOf { it.mentionCount } > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + ) + } + IconButton(onClick = { + overflowInitialMenu = AppBarMenu.Main + showOverflowMenu = !showOverflowMenu + }) { + 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() + ) { + Surface( + shape = RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomStart = 12.dp, + bottomEnd = 12.dp + ), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + InlineOverflowMenu( + isLoggedIn = isLoggedIn, + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, + onDismiss = { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + }, + initialMenu = overflowInitialMenu, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = { showLogoutDialog = true }, + onManageChannels = { showManageChannelsDialog = true }, + onOpenChannel = onOpenChannel, + onRemoveChannel = { showRemoveChannelDialog = true }, + onReportChannel = onReportChannel, + onBlockChannel = { showBlockChannelDialog = true }, + onReloadEmotes = { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + onReloadEmotes() + }, + onReconnect = { + channelManagementViewModel.reconnect() + onReconnect() + }, + onClearChat = { showClearChatDialog = true }, + onToggleStream = { + activeChannel?.let { streamViewModel.toggleStream(it) } + }, + onOpenSettings = onNavigateToSettings + ) + } + } + } + } + } + } + } + } + + // Emote Menu Layer - slides up/down independently of keyboard + // Fast tween to match system keyboard animation speed + AnimatedVisibility( + visible = inputState.isEmoteMenuOpen, + enter = slideInVertically(animationSpec = tween(durationMillis = 140), initialOffsetY = { it }), + exit = slideOutVertically(animationSpec = tween(durationMillis = 140), targetOffsetY = { it }), + modifier = Modifier.align(Alignment.BottomCenter) + ) { Box( modifier = Modifier - .align(Alignment.BottomCenter) .fillMaxWidth() - .height(targetMenuHeight) + .height(totalMenuHeight) .graphicsLayer { val scale = 1f - (backProgress * 0.1f) scaleX = scale @@ -739,12 +1122,13 @@ fun MainScreen( alpha = 1f - backProgress translationY = backProgress * 100f } - .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) { EmoteMenu( onEmoteClick = { code, _ -> chatInputViewModel.insertText("$code ") - } + }, + modifier = Modifier.fillMaxSize() ) } } @@ -857,7 +1241,7 @@ fun MainScreen( } } - if (showInputState && !isFullscreen && isKeyboardVisible) { + if (showInputState && isKeyboardVisible) { SuggestionDropdown( suggestions = inputState.suggestions, onSuggestionClick = chatInputViewModel::applySuggestion, @@ -869,4 +1253,5 @@ fun MainScreen( ) } } -} \ No newline at end of file +} + diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt new file mode 100644 index 000000000..ec6fe8d47 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -0,0 +1,162 @@ +package com.flxrs.dankchat.main.compose + +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +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.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName + +@Composable +fun StreamView( + channel: UserName, + streamViewModel: StreamViewModel, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + // 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) } + val webView = remember { + streamViewModel.getOrCreateWebView().also { wv -> + wv.setBackgroundColor(android.graphics.Color.TRANSPARENT) + wv.webViewClient = StreamComposeWebViewClient( + onPageFinished = { isPageLoaded = true } + ) + } + } + + // 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.currentStreamedChannel.value == null) { + streamViewModel.destroyWebView(webView) + } + } + } + + Box( + modifier = modifier + .statusBarsPadding() + .fillMaxWidth() + .background(Color.Black) + ) { + 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 + } + webView + }, + update = { _ -> + // For subsequent opens: load URL while attached + streamViewModel.setStream(channel, webView) + }, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + ) + } + + IconButton( + onClick = onClose, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(36.dp) + .background( + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f), + shape = CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +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/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt new file mode 100644 index 000000000..c1e3b5e0f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt @@ -0,0 +1,77 @@ +package com.flxrs.dankchat.main.compose + +import android.annotation.SuppressLint +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository +import com.flxrs.dankchat.main.stream.StreamWebView +import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class StreamViewModel( + application: Application, + private val streamDataRepository: StreamDataRepository, + private val streamsSettingsDataStore: StreamsSettingsDataStore, +) : AndroidViewModel(application) { + + private val _currentStreamedChannel = MutableStateFlow(null) + val currentStreamedChannel: StateFlow = _currentStreamedChannel.asStateFlow() + + 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 } + } + + fun closeStream() { + _currentStreamedChannel.value = null + } + + override fun onCleared() { + cachedWebView?.destroy() + cachedWebView = null + lastStreamedChannel = null + super.onCleared() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt new file mode 100644 index 000000000..6b551f188 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt @@ -0,0 +1,190 @@ +package com.flxrs.dankchat.main.compose.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +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.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.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.twitch.message.RoomState + +private enum class ParameterDialogType { + SLOW_MODE, + FOLLOWER_MODE +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun RoomStateDialog( + roomState: RoomState?, + onSendCommand: (String) -> Unit, + onDismiss: () -> Unit, +) { + var parameterDialog by remember { mutableStateOf(null) } + + parameterDialog?.let { type -> + val (title, hint, defaultValue, commandPrefix) = when (type) { + ParameterDialogType.SLOW_MODE -> listOf( + R.string.room_state_slow_mode, + R.string.seconds, + "30", + "/slow" + ) + ParameterDialogType.FOLLOWER_MODE -> listOf( + R.string.room_state_follower_only, + R.string.minutes, + "10", + "/followers" + ) + } + + var inputValue by remember { mutableStateOf(defaultValue as String) } + + AlertDialog( + onDismissRequest = { parameterDialog = null }, + title = { Text(stringResource(title as Int)) }, + text = { + OutlinedTextField( + value = inputValue, + onValueChange = { inputValue = it }, + label = { Text(stringResource(hint as Int)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { + onSendCommand("$commandPrefix $inputValue") + parameterDialog = null + onDismiss() + }) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { parameterDialog = null }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + 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 = { + if (isSlowMode) { + onSendCommand("/slowoff") + onDismiss() + } else { + parameterDialog = ParameterDialogType.SLOW_MODE + } + }, + label = { + val label = stringResource(R.string.room_state_slow_mode) + Text(if (isSlowMode && slowModeWaitTime != null) "$label (${slowModeWaitTime}s)" 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 = { + if (isFollowerOnly) { + onSendCommand("/followersoff") + onDismiss() + } else { + parameterDialog = ParameterDialogType.FOLLOWER_MODE + } + }, + label = { + val label = stringResource(R.string.room_state_follower_only) + Text(if (isFollowerOnly && followerDuration != null && followerDuration > 0) "$label (${followerDuration}m)" else label) + }, + leadingIcon = if (isFollowerOnly) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null, + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt index e05746187..7d9d5718c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt @@ -4,10 +4,13 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.ui.platform.LocalDensity import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -55,12 +58,12 @@ fun EmoteMenu( Surface( modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surfaceContainerHigh + color = MaterialTheme.colorScheme.surfaceContainerHighest ) { Column(modifier = Modifier.fillMaxSize()) { PrimaryTabRow( selectedTabIndex = pagerState.currentPage, - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest ) { tabItems.forEachIndexed { index, tabItem -> Tab( @@ -99,10 +102,12 @@ fun EmoteMenu( ) } } else { + val navBarBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current) + val navBarBottomDp = with(LocalDensity.current) { navBarBottom.toDp() } LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 48.dp), modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(8.dp), + contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + navBarBottomDp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index e1af229f6..c554b23af 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -414,4 +414,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ VIP Gründer Abonnent + Wähle benutzerdefinierte Highlight Farbe + Standard + Farbe wählen diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index bc5064543..b2b118f8e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -407,4 +407,7 @@ VIP Founder Subscriber + Pick custom highlight color + Default + Choose Color diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 406307a3a..aad74fc7f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -322,10 +322,12 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Usuarios Lista Negra de Usuarios Twitch + Emblemas Deshacer Elemento eliminado Usuario %1$s desbloqueado Error al desbloquear el usuario %1$s + Emblema Error al bloquear el usuario %1$s Tu usuario Suscripciones y Eventos @@ -338,6 +340,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Crea notificaciones y destaca mensajes basados en ciertos patrones. Crea notificaciones y destaca los mensajes de ciertos usuarios. Desactiva las notificaciones y los destacados de ciertos usuarios (ej. bots). + Crea notificaciones y destaca los mensajes de los usuarios en función de los emblemas. Ignorar mensajes basados en ciertos patrones. Ignorar mensajes de ciertos usuarios. Gestiona los usuarios bloqueados de Twitch. @@ -402,4 +405,16 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Mostrar categoría del stream Muestra también la categoría del stream Alternar entrada + Streamer + Administrador + Staff + Moderador + Moderador principal + Verificado + VIP + Fundador + Suscriptor + Elegir color de resaltado personalizado + Predeterminado + Elegir color diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 49c94ae01..098521678 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -211,6 +211,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Lukee vain viestin ääneen Lukee käyttäjän ja viestin Viestin muoto + Ohittaa URL-osoitteet TTS:ssä TTS Ruudulliset viivat Erota kukin rivi erillaisella taustan kirkkaudella diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 645b32330..1c383a6bf 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -320,10 +320,12 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Użytkownicy Użytkownicy na Czarnej liście Twitch + Odznaki Cofnij Element został usunięty Odblokowano użytkownika %1$s Nie udało się odblokować użytkownika %1$s + Odznaka Nie udało się zablokować użytkownika %1$s Twoja nazwa użytkownika Subskrypcje i Wydarzenia @@ -336,6 +338,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Tworzy powiadomienia i wyróżnia wiadomości na podstawie określonych wzorów. Tworzy powiadomienia i wyróżnia wiadomości od określonych użytkowników. Wyłącz powiadomienia i wyróżnienia od określonych użytkowników (np. botów) + Utwórz powiadomienia i wyróżnienia wiadomości od użytkowników na podstawie odznak. Ignoruj wiadomości na podstawie określonych wzorów. Ignoruj wiadomości od określonych użytkowników. Zarządzaj zablokowanymi użytkownikami. @@ -383,6 +386,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pomniejsz Powiększ Wróć + Wspólny Czat Na żywo z %1$d widzem przez %2$s Na żywo z %1$d widzami przez %2$s @@ -395,4 +399,26 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %d miesięcy %d miesięcy + Licencje Open Source + + Na żywo z %1$d widzem w %2$s od %3$s + Na żywo z %1$d widzami w %2$s od %3$s + Na żywo z %1$d widzami w %2$s od %3$s + Na żywo z %1$d widzami w %2$s od %3$s + + Pokaż kategorię transmisji + Wyświetlaj również kategorię transmisji + Przełącz pole wprowadzania + Nadawca + Admin + Personel + Moderator + Główny Moderator + Zweryfikowane + VIP + Założyciel + Subskrybent + Wybierz niestandardowy kolor podświetlenia + Domyślny + Wybierz Kolor diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 04b5ea691..e91d07899 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -322,10 +322,12 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Kullanıcılar Kara Listeli Kullanıcılar Twitch + Rozetler Geri al Öge kaldırıldı %1$s kullanıcısının engeli kaldırıldı %1$s kullanıcısının engeli kaldırılamadı + Rozet %1$s kullanıcısı engellenemedi Kullanıcı adınız Abonelikler ile Etkinlikler @@ -338,6 +340,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Belli şablonlara göre bildirimler oluşturup mesajları öne çıkarır. Belli kullanıcılardan bildirimler oluşturup mesajlar öne çıkarır. Belli kullanıcılardan (örneğin botlardan) bildirimler ile öne çıkarmaları etkisizleştirir. + Rozetlere göre kullanıcıların mesajlarından bildirimler ve vurgular oluştur. Belli şablonlara göre mesajları yoksayar. Belli kullanıcılardan gelen mesajları yok say. Engellenen Twitch kullanıcılarını yönet. @@ -402,4 +405,16 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Yayın kategorisini göster Yayın kategorisini de göster Girişi Değiştir + Yayıncı + Yönetici + Ekip + Moderatör + Baş moderatör + Doğrulandı + VIP + Kurucu + Abone + Özel vurgu rengi seç + Varsayılan + Renk Seç diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 330a42760..d68c7e792 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,6 +128,17 @@ 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 + Fullscreen + Exit fullscreen + Hide input +Room state + Emote only + Subscriber only + Slow mode + Unique chat (R9K) + Follower only Account Login again Logout diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b94dcd0af..470bf05ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,39 +1,39 @@ [versions] -kotlin = "2.3.0" +kotlin = "2.3.10" coroutines = "1.10.2" -serialization = "1.9.0" +serialization = "1.10.0" datetime = "0.7.1-0.6.x-compat" immutable = "0.4.0" -ktor = "3.3.3" +ktor = "3.4.0" coil = "3.3.0" okhttp = "5.3.2" -ksp = "2.3.4" +ksp = "2.3.5" koin = "4.1.1" koin-annotations = "2.3.1" about-libraries = "13.2.1" androidGradlePlugin = "8.13.2" androidDesugarLibs = "2.1.5" -androidxActivity = "1.12.2" +androidxActivity = "1.12.4" androidxBrowser = "1.9.0" androidxConstraintLayout = "2.2.1" androidxCore = "1.17.0" androidxEmoji2 = "1.6.0" androidxExif = "1.4.2" androidxFragment = "1.8.9" -androidxTransition = "1.6.0" +androidxTransition = "1.7.0" androidxLifecycle = "2.10.0" androidxMedia = "1.7.1" -androidxNavigation = "2.9.5" +androidxNavigation = "2.9.7" androidxRecyclerview = "1.4.0" androidxViewpager2 = "1.1.0" androidxRoom = "2.8.4" androidxWebkit = "1.15.0" androidxDataStore = "1.2.0" -compose = "1.10.0" +compose = "1.10.3" compose-icons = "1.7.8" -compose-materia3 = "1.5.0-alpha11" -compose-unstyled = "1.49.3" +compose-materia3 = "1.5.0-alpha14" +compose-unstyled = "1.49.6" material = "1.13.0" flexBox = "3.0.0" autoLinkText = "2.0.2" @@ -43,8 +43,8 @@ processPhoenix = "3.0.0" colorPicker = "3.1.0" reorderable = "2.4.3" -junit = "6.0.1" -mockk = "1.14.7" +junit = "6.0.2" +mockk = "1.14.9" [libraries] android-desugar-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "androidDesugarLibs" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..61285a659d17295f1de7c53e24fdf13ad755c379 100644 GIT binary patch delta 37988 zcmX6@V|bli*Gyxa*tTsajcwbu)95rhv2ELp-Pl&+oY+>=r1|>1-;aI&zOTJz_N+B) z9#P&Gv}#H23%q7=tU;GV-|;-tohf=I~hj-MKNe%d3eh&|2-wPA>Ee zMNrvEdyVSI`lvDy^z`gwVkn}LK|GSq#|4JnxoIRO@sx!|eY?;bc>3*K_2AXEE_$R{ zzCVzv3UKiDb5r_h5D*ZZ5Gi+pL@8*f(!iG1x>gdb9^Yv>r}mh(L3OFb;);CzTapwz zf*i|?OK0?9kw_P?-0dFJtLi=zod6Wn?%aD|P%jYTC%iX)k0Vd>Vbm^Cr+C9_<`m40 zM^@Lgu3F}@42za*z}FZG8H@~yghPxY23DilF(fmO%LhdnWy_>0YdUU%{5;eNLSXXl zUnx6g^xx`|70?R~2k4=9*}u3!x!&jn08l8EddKmclPN&pp#^~95+?=Q%XO*?SHv{B znC+V^K--hOSb9;Y5YgB1Ej3fir@e4s?^di<$}xQHuJ$q9?N!Bv!`7I6)5sbU#Lw1u^S7sb(g%zNh3L5MJQsdwRb zgNo>59Bh1jM9C5avMI{R90Kw05r4looJRr#4qh*PUNQS31%o#@dU69Nb{wvnpC=mn zcY`1@hbV^re0-c7@lN8bd570AB1N~=VPVbKaVf?8DYvLWmcdQ+38(I$J+%Pl`B!V> zZq%>Y`%Vt>v8{!@1PO9E2 z9VBjQeL_arEHz!fGJkWBR?n$iC=29KvaHy#Um6^Nh-;kO0n9to04%I0VDXCW&Btps3x;+T81snc7-CrYOO1 zBwrOxlYveR3vF?GBNGFK(sl#BMS^d}9ZAd|AuI~qjv&_lj;$rIO{nGxgiPL|9DKXC z#tSNLxrea`Z|G>h1HZmhBsPlM5D1TDN+yJ5N^b0-)-S?aZIOvmD<@f5T70wrgb#~L za_t)z{ST{ujY^K=AR!<&q5jdEF{PgoCnXw_80e&eDTWr54k^=6hJ}7>BqX-6Sfa*O zwVX*76(t8X%1|D_zS(`{=Gwk?X;d>jj(W%YDu$UVi3$8JI>{GEPR3=|qu_0AlW$|~ z?fv{xK-v#cOEGqPc6)1e7piu!+LzeYWUcE(>7pCF>ncpr8O-(Z6US0#5K{>2aOl)rz?K{}OE1UoTv+&+5Y0HkKiPPx_ zSUMqKtF!#T&Cp4YDQDIn9b;#M?Zx0qqt5TlH)Vr5!Xg@RQo&-HV|Ik@n=3Oamu2lh z49_-ckP&y{-UyE%0O8+5(Hp5n`BHJk0y(H^HQ+mE^&zM2ap4%A~gIc#u*(J)5 zFWKk7)#JdESw8EXxkA^MIWExbt1a0@>sH2q;J4sfvuv@)8Yj~R*bKk6ac2b^px5*s z;S0EVw7#6!03@ahulYnKSqr8O6fq3?}t12hy0(&)WAI^$5R$gvv z@QTmbJl?Dt^=s$=+Cf{OGen!c|6sH&L~`a>NPKsP3{U~V7nSYIyfY5F10YH9u)4{jG`)}f1N+J)`LBYoYP2OG7uKJx2PLrk zT)QlSITdY?d3m;=xZj!6HJ(ntr@OTsF?H7IVL#|XSiL@jJFN!e0Ny9P&*E-|UWWDt z{>&}87BdLGssBIReYp{#Gx&$QHt7G!3L!aliV6-bP-9aYO&He@IvTzqQ6`5*8V1l7 zj;RBnl;-^y~v2}NI+ zyRv85wprnBpSkOu717VWKvYx2T@E4O^Q9TsrgiWM*-U3ePs>E7x%yfcdFZeY{44uN z69!xlWP^EuW?t>AIP)rU@l~4AuvzOoi>lqIw8L?+mEJ4S&zn>+p2A#XzT9ZwRZ4+q zm~E|jq`S;ELjn_c$IUB&{aSFr;Zg6BVl~l9PZ`Q=u$_loMn+rQiUVv{9j%5lM_L+( zo=fA*eCZ=s=NRAK;=A)*BTh!T4lp*K-qo9di~1RfrX3^>-HY&#vrca9;i&=FNC?CL z;-KwYPy<_~f}3;naG%*P5HJP2qbu}5Mj2M)?>lm*1(SDrK4_zgHO@yZF=z*J9xI18 z5{=F=b8U}5tr*<3XBnh6?shxr2>l?LFYL&wztoJ~>N zHvJ8pWBAHvq`zfaPQcFUiDGRr0L@xX3=JqX(^Tpz1GR0mdn@^%aIw`r^AmT2itv2^LM4_W^UDgrqS>F zMf918L|84oF#mQq*L! z&>!lVl~2Q=vbfpt<_`DU-D;n>J`H!XRsHknkuab)&H#9ADq_iOw+^_w?g5l^%e+u+ zh?oj&sTU`Oqw>XsVaem8eA%jSNPV2Yh-qm7b5)(vhLTg1(QU{TD=(cBxan3lB42%e z`HyiWi<_#NP!JF)aQ|c66uu(x!h~oAKL~{hz?336j+|^ulS9bVZ3@W%sRR>CpqI{t zb~>s_?2R%Nww^UJ%>-R1cZ1u?>p!xwp}^Hz?$k1sJl;@O@K&_D2`v2hCHixfA#iS* z#nDtlynj0PA^+vRXU*g9{hdD$dOnI8q{C_=vh);SE3TyMp@HNTk(>f7lBKgNavnvN*|4K_6V&(sTiVPiK40Sb>EmM&r^LVwdWJUm@F-}i0yg8QMUWddZmW)J&?gyYq#0bS3AO_c+c%_lCemS1~~WLa!>+C>l}I5AS5ra>86N z`IK^Ng`*ayum9rwCR{DhQ=hvP&rYCj1En1mIeWF1KNtOH;ACV?m!bG~@GEH0>aZH$ za*8sc1Cmj~|B~hD6;c?m(gz8e|3rJTitJ|TlxL#c&zhHqmMfh^H^txJ0cr2&e&s14 z(7R^4j5SiVS$?jqA-oD~tD7D19K#0mc2#xL;??uGEMCPKZT|u`&vY(vjH20!tZ>kj zd=U&uY)mop$M2`Qw61h~#?J|{9VWqlDeS}1`bAp;+iLDr5KDGGET2Sf5u+P!={UmE zBp{h)mlf#EkaJua@h)3a(;SPFfI3+3V< zDekSd)8r^4K~*u_lWVUy%8iX!U>4svfQb|u$1_|${+P)DM8vA>NhXSa$bnV`6>v0M z@70rW&6j<0WJI_Spa8vr<%3K3KFahU)hsPyY6}C-u2CSj)#8sd@fRuN#k$wH8Y1CK zBBz=GxoCu@Qmx6CA*=fj&8+XWC&_nw`DjT(tu&yK>bpsIw#cAiJOSA2?=lNa*L3Aa z4D|vt*eiy2QHi7Ucg2`mn{f{cEF+O(i+L%7KCA_)6)ND^@g?}7P{Ly?Ss$XoV_b4O zM!;AzR}%2Xk7aA8dT{KHZ1mPHBzylFv$}ahv8m$;XepbuvP~~_puD&$Y~L;tfaJibfB*SWC z*(ZJ7);`c5sj-&vSbyvMZdWu+_K=f3fuYF0G!8^S%6eL|-hNLr<#c?YynLL)R&!$Q zWTbP_kgx}R?Hpen8~`AfdW~VQsI91_G0G>z=gWwMTV`Y+g@DXZDHkJ`@lQ+JI<2p(#48RjPr zbnYy^9C6!&o=z}=(MF; zE5nC_*BY;~gOU3+u^wjCWz`Sg7s9ero~sds8{StZw1j;hEdQK}I()6MM(R(*VR8Ede!NZxC)Y&G=G$whxxiXV?JTemD6@ysi~^qCQG`UFaY%z zB!ab{_Otdr&XWF}sDDQe?4}Ogl-I{==aXAna|)!&IH4_sCqpLc@O< ziJGO-OiuVR=tV$_*fAP8dJ62oT4UMR${9NqI;4)J9*mLQf=9Atq>+`qd%U6O4}*z9 zX~XzNdWGVvCKAC3L)hy{uL<}C9I*UIF4=w zLLt?jz)0+mW#&Gn1D~iSbulJxaCLw+)x5;Cg0H{=vFK24)HLFgkF*(w=j$-Hpv$SH$qlmCws_|Dgk$AEG_31BM?)Q4dq|=EHV-k@ zAFppMXXxdu{bl{9Rtq-pE6@9_lBUVfUyjZk*5g?pFm2PS!7I#=P&P%2UkW(Tb5zbs zb9uD&2>42{J9+SiXv1W9=ty1)cYwa%D`sm8o;g$Wm5t4-Fd`~CNB+8*Y^dwg<85M}zjlEOeif~q zos6Yr%nrx#+DnSN7XmB^L?#6JE(rHA z;aOD@PYAQoTjZK;sQ@+c(pTdTNuY&F8^z!K<2{T20DPWO;5FOuq@OC%n6bZAexswS z9;e6{!lS{X5&ql83{N0QXqRslc!j)sfSjSw-Go2VYE=8eWycEqo$>yR_M7~RigxYDK{J+N>F--=v2I{}q^*ea`6v2^t08s< zd(IfOp_&^B?mmSB0LG;LkNfRr0!Zu0*DRUKj=(=ZF)8{YGr?k~(3)x;H8Y`XByk~S zv|+K%EHiTZLb!oBB%|jFQBmyyyQ@3an+vIhuq2QKM!&{0TN;+q5(%k+bJw-{4LhX( zT~y;SU45QO%v>{3ojLE!;@F~Cr^G9lNui|*kKPxA#c3IXWWu&SwqjqG;837G!#`co z=w6)_W40pl_@y)?Tr^QvFg$UwCUjbtLNsLB<-aCx`H1XL@!wdL@&9AhCZz&U<3brz z4C^llQb7TIQt7Lvp_su&nPHh>m}UqFS^-Kj1UT;Lql@F+Zs{F^Mv1!5`6_{&A&E)) zGlC=ENvxB4Tglp|?;-ET@Ob)0R5a)d-k8wP$-%+Ov`p*ICt)zbc}ulR4ZRktz@PM) zz?xHgw+kxJgqaT~4rd0!QOfJrkX@(>;=VICf3{j}km zONL_(giC}2Weaw_U8lJ06gPq}+G2@rmRNdX-Q3`nx*ba<@c zV%x8LY_04qeD9THm8xet1f%olCOb!PLQWoQiVeR9I;W|2IO*=bu@)gy6S@|>JF8=P zVvc}CW%UN{$vXZw%q3xXA zT2Ucb;d&DdtaXQN?(6L_2EQ-g-uCU|i~h*_sb}sdp5F(O8_5IR;Lq5&045Fr)KD-b z0OaypT*qs*G^e}aG^QysgjfrT5cVF^`Dz})ZY4EFxMTWpHo#W)L^mx<^cj5lS6a>P zuR3`}{A@?^%3|YQ#*HxBy#m=^3t^i~;e*q4*($=qevbD?t!A`bNHLRJl5A}$<`?sO z#;-2ZZ}gM(w_BOlk!$nL1A}qjtTIbl^ZNz}hbSqu#=l`8kAH;b(AtU) ze7y%u#9?v)$HqtTX?TOo?L86|PclE=RH`Cc&QC=Z`;Oa8C!~Acmp{11pDya~%qY`XEq@3rN91-8K?!hC%*VR{nvRzZFtf&dgxU1D zlBl(;ZEU-!)jRhuqk7Osu~A+F%h0r#u!?aQXuM-L=1qp%+YL6IL@C=IoOGm!)R(+K*l_8GAFvONs^N&5_*jIZ*%?}Noj$@^hb33^e}BjbST`=&KJPdl zXh9KYlWS>a(^fKt9!R^aD(!Z`c0Hdky>19Y zF6ry-H1XmU$}X~^jS|n(L|;k4eFB= zcA;nZQS%f7XGAG67&%23U?md>>O`npF|NrR6GvHd3oW|8`A7-A$`ohlcq({2glYHC z9VX@ohWj!DqbEx587lz9-PLRgJJQ{+kJg(Wu?-Ji=(Y!(F=xYr0%(hi{6|AGdI*8e z=%i4?H?OHU|{{Sa1EGYfY5W$oLhQG$Rkijy6WS+NNK(dXthd zBEe{cTPLn|S4amhH4+Wyvc7%BldUB0ZGg4_cgHM*KoS5!DxbUj1_Oi3^Ke)2PLtKs z+usBElZJ`iH^7&#VV7S7)mb%swhgl-w;J=bgOW-mT-&);qO?aWN=OW2Q^+lp2bNck zS2_0zCj$Yfou_;_+H-(71wS;iF{&MBuk_&ntYM_4PUi7hqnE@+2)7N3rrVTAQDvQ6 zTeElY;vLTS5QU8uE2`?I`AJEhB&L-!9s@w7_6x?^368g@AB0Vs?U0+V4al~h)R-Qk z3ti;CaZ_=}{#Nmq8`h4*9pK(A9_5)JR&Lmt8^#XAWBp2k2#}r{OPhkUtTT!JU6&BX zab`EB95Nu@c{k)x{(LK##t17__jlO{xr*>d^WBY?NN_((>yW8<4Q5@R{uQd=;^$uf zc(QiCJc^cV3SXg(1)CEl?e;GjkAc7_u0^J}$sm4HZ}%1!RW6D2q?vk=q2ZJj1?_yI z@hMAg8Cds)NdOKU;66$KW@#SCMxiH&fG*SLhAO(={8u#`-WC9K$?xE_zwQJO&ncU zZxKrdi3)nyT=RQaTi-P7iUvXI{$v_D2@S0`+*Ma z+Vud1INZ~aKFqyLVj-|dyjtqc-L6lQ$FZpac>cv=ycW*ODr&5r7VjEvlAZY9ZXq-M zB%3k##=}l0@{C`nNOh^w3fn0BU>x7U30UA@&fC7A)5+ctV{jEU z@xs+#R78r)DN9*H9@=MI%2p~i6#k2FVLo){7oo+ekK|)m#METfA93mBAeMe9@K=no zXzh_dw*2{Hjx4|(F>6RU9sB&qFp!+0W##^gHSpD>RmMi~Xe+#2AdVe5Prq0jOO&3N zBbF$mUZ&oLg>ghwbBj%Xku5H#w;|H=N0Jx~^_C)6Ig!98jgGZ}ZK|f+0b?y}yePA%_4BOHau;X2?92kYeFPzTm zm(C{oPyI{zq!X}tcnR?WSIMN1w}Skk54@lb*uM`r%FC(>F7r1bbBjgkqG164hr5q> zdasmtHeH|&rVL)tC^YY|E_ERnj#Z95LU1C3F8X^kIwK4QRb`y*S)(8oW6pL*H@HNb z)z5F+qbGH0$9Gd3sV#rQ_@!L5xWAKL0rPa<=DTn)62JX6*0X9IFe&qgj&K z?}?PCzcP3D_0tFvaj3&->%KmQ?2KAUC-K${ueIpP%yiHx+bifEJ&QN^29Q8Q;#il)}{(0pv_GbV**ux;%MO z&wulG^8cnX93Lb|m;%5ddd*mFMoTcEj{0Su6Z_RHi_!IE&DLdu$X=!vV{Mq|7R1qJ>Gbh){7;VHu=b53kqphD1CqF+|l0{^k-(GgfnVmxFu(@BZ zV0>e&*oIs3=I~jxsmYR~NC{F}@U|77rMB?JN?FLk&iBSmUuV~Dq0t%^s5LJSa5}q9 zCX)2d?oi`rh_&r3HRx;cgEw@9D<1$s2>?{;=A+_@SO_VHr{Mb)2}@)JZ&X&E#~=IysPiAE0z<)0sNf>1GO%|YB{$5Np5$Z5xFV8*+%SmI!{$!~mVk2i zulpRi3W_Thn}Tow;c_of(RO@>oR{cL!>*Ry8bQ*4F8R{=gXo;{%yMzQkAmSlm6Uk} zd|SaS_e;TSjh%s3Fos*+$e0;Jr3aOCxS%F6j>L_(W7~9Hh_5ath~iw9uSrmDCoiKFuNKvZo3k`{s2>Fky5yX|JPXiWy;9D?i%Bk(a1-}9wS|x_Xi*kj2};Jr)l*{9LVp^ zw9*TCGW*cl-_=jM_F|TwuS50`2Rphte_<49c@3p+ur?nZh(SQ52;pEW{WHfFgPL|` zQ%={{f@xk<(Q!AKOGOwNio}1<7MS9vCX-~P%#XpD0T`M*6HzfbL@C$ArY2yzju ze+y4&22eN95H-#{m4vW8)p{+;) zwDPiZJ9y;Uw0o`#n9l|1FP*3blpD6j?O;#Rtq*0p$Cz{6lSD*#_;C`d16W_v;9|v; zv!ob@TY7Vs}))4t(V+I)8aZomfN1K!*q$ zA_Q7IH1%zQ2(z(!_6k|glw9sz#f-iX|*rR*)| ztG=r|A7u#;HHcN{^jw^FwpCY+4dKMf5KWt-D}2Sk*tceJQ&We~7Xy-m1tEdt5wUv; zKr#bP-_K0{DzpLd+gnNWVg#)|T5$;Qcl8EC8lTF1hQLUztSiDe4E;I$`%+NKFR=n39PLSJHv&u(EPdM)f>ha@TGaM~RAbw~9$yz<`R zJv3Sv$cle;0?%<`vg_T?hyQS-ORfCn#Yy9FI^z-~^|X+BY^21v-9us5qQ|nOXOooC zZ*Bq7)KeGPW5`AeIRG4gg?0~9_*LqvMFjA0x&eQ($8YRYRHI95 zHkLBeibPQGHI1M-p5l0kRGdEf>jve5xy6AR1#dM~kV6vBu0PrGzE3toeE#xm zu=MtlH7E=8qeLv8%~0mD6tU*<4X)q%oU(#L5?Pal=E+dgUFF8bk{gK3snCPM*+D=T zc9lWsAqT}YwmfAL@V3Ns@_>Gez3N|PtUA5vxenBM;0`hW@Zjjf{S7NTRH*WtSjS|h z1ROZowrWoWno2Q6x4eut$S5R``*l~9pG6?02qS%|MyBH^-#>g4Fd>V)^g1FdY_*Sn zm4KdR7U=;D8(s&Ub(q2U8Rd(%I z?0v1zIJP}U=?1>Q8k<`@LFk;y5}78(A4ZXqmGQ0_7NrX2p2wEHpPecM{4k-Pj!?PN zW1dHZIWP3!l3bG{<7_yS)&WY}*!Dx`d&jWV)Vod=ASHdGdj|=?ZbNB<3h;UmET!mK zDxvE8|F@nFiMd4!`}aJlrxbA$r|1X>0;lAMzG4YmI4rHcRj$;Ypu?c@B-Cw^$UE8X63ZAf0mt&Q zy~4n552vLQdP{GDqSLL`1M#IkU7~Y)?y!l4!Vrr&W#>%A)Rv5o~2ZUj=LX*GR zQFtk9n>Gu+JSf_I4&wd(rNtw7WbiXfUelXJ_1AiDO;L0i1JN=x!RH}yt!7iSKC+c2 zgRZ1e;VMOD)*xNk? zkT)HcC8r?ulef=RMd$JtLw-t#aI``~m)nW@9Jpb=5p@#%h;86qg`Dpj320#`AKgGq%ChgU;Ny-eAy zAz&1Y5s_mDlvCzI+`#W*eQ?X?=+u(WBkE=HR2fhgpE+bfu>r;B5zVqUJ^;uwaKoYT z_puRMn6m@-Gajg>pak==g<*D6>EYSwK>j`IPc-F5H=}d z)uw2%6{o2K&=H5DE*_WBv9hvZ6@mgT9Mvd6Z5kf%Vw|&VpWiR^!fnhW0=Z$ju?ySm z{(RtJ(Sh}QD2r>g{3sMBwm-H+7Zf&qs%DPkS9E=sRLY_DWUKPx>m|XY6|y) zXO|R;c%*qmEV_lR-+UwB=Js=*A6J|xiVRFMvD|FcMSaOsDOGyxAlJxhUeU5W(zv*` zCNu<8Bz7Yb$>KI-IB*8tHpe4AFH$NDri3eQa!){xc%WN%7Obt-y6bJ77c45YxpG27k`w;2g1afr1&yaeL6VJ zO_;=HA|<`iD-96p&MF^id(fLTR~;oSKDh;oo2fYN&PdlW-ssIf zQ^GyOG(K6@pxsyo*j!Ol{kjf_65Az7YT2N!w^$!wc<%i*h!3@ewm;B_H^PqR#UJGMguO%A$!i8a6S6s67|z}yS3l})Sb;CiiPgTL-uUd%h>%In z&2C^0$}Q0Ve;ncHw+Bqgyz2HHVVeI-Aj0?$YTeOu`($eiGNaie2RbI|%-yPWmSx!3 zHKVRDlnmZcw}fYWEfOUHJ-L5b)FI3h^GrVUz}OvChn9Wwsg1|hcs&r&-Vp=R;TRi@ zz0tqpAh}dpEMWyjhx7g+O|WjH_9xwZi0G2JHrJ~aT+xuZ*%PikJyYHusndOlWIdf3 zt4OLYVJ*lT$bUDzk(i&%9|cG7O(vFIFamrUMU+h%INu`!QQeY@;~l++7#CsCc3+VzA)c6_`+jHlaud(lFpWkhi zrBR54UrUy=5k^A;E3f@-R%+y*=1hEEAW&{K_oilM{@hiVQmo|u;NwGF=K6)KnUAwE zu70AIdCbo4Jdx-*e5lx^IwLz{l-9Lp3uK5Z*)EhF)Wj*O7v6t0eZ2e~+3Nk?9;A;y z^gU<7wLoJMBM)6Kk2;oRT;O`-)z_tk7sWy!x6?X3`2s+*)M=~m{sYup_ zdR;Nwc2d*Qo!h47bR~@=Z7HUFuT1@LD{IpQL>U}k zy(F@_@Gl(#3rYA1+HqbvgtrL?u0K1*5(WG2i$y-C|!Qv;*4o4lPnl447`49&@0`ghufkx$E@* z-|{(o9hb@fXEoCN&ua7$Qv|9T{mV9E2~&oT{2)UdxoLPRv(3>Yr? z&Ii3~N#HNkN$ucpqlV*fJ5ddN{WO{&(YQ!AiEK;dfL|${q|bCh<3PBNt<6*UJdK$t z^L|!N6Kwus+upyga(SKQZq+v^E!Jc=a-ZlCslyE991KzT#K{w#I0td9Z~8+Cwx@xa z;b?U2i@^wIWs6kvvPwk5($d)>!CC^UQPe52#66rGQ{z3Vo!r%&bOgJcew<2toEk)_ z&^Rwgs<8SrZns^{D!?KyHp)f}Wm%h2NYokwi(vWCVn2_I_8zO4zML9NI xt3G?g z4&W%tBqXPbR`Dfgu>2G0TRV&4bOw6_Oz;EyI?i0{oODJ_4RFbbY2RF|&r|=*#sMY0 z^C#;!#u8O8tq#gKuO^NKL!6Empu_>FK3#2qJJ>Gg2Xel_lYOijF0X4d*)|59)BO|T zbURajp;K0G60wrdr5zug$aA=!F{BTlUpz*+?~5|qnzM^2-)J~`dY|bTblUUB2E0zZ zTVU9xOlwKgGs`C~SzmLe70MsFd5R%^#gG5Fe3Ew+Y@7c|KK8HV)<8aT1VdaD@53hs zK3VM$cX*5nGAbswuZ9B6S5JK1%{5s~{ABm!0TL{o--m-y_e#*5;Gks<%U4#kR=7EL zfdL<|duXWGB1Xo2BtemLawd&4`z5*w2gbEEKX_nZG}cRNIdHbz1`hQ=nDsa0xI&=n84=S@gGA2DHTxQF0jjouQnFsD*`p#;*Xg)EAP?cklFQb>(sA} zqNk`Su_t$9u&Lr6nlrh_xaFqd&PVrLKB?HbeLk9Nmy1i)h$6BN$+5&R?!ns!q3|_` zIU3m-`iV13Iwu;dUqV*uj0CvF&z5e_ryG5NqxHN$QUepAY&@Sf0-)FUqLKGPE?Tuybs0 zflp2+u!wi|uhII!bB}KtElo6FEaxecon!{PqX04^gX-k2woe$JhG1$>RKotzO>vVX zf~U5Pf+n937U@14ClFNVgE3AL>-48h;3<)wwUITF_%(1WH zt`@dMMY1U6RpwZhRTz1f@oPD?J~KglvhXauZl_U5!YyO@N!e(v>YEx#ue9$_$}Klc zs*8wd-HSapLJ!kDq;u0txcz@oOi2^~qFdeV`n?vl9v&LL=}oqoP8OqVAI@`b-wuJV z#+?@iA-7*ULLx$N1cjJ#h|QcqZoFJLn_I{uu?x*pMmvmx_kgMF0znV&_ztnBr@!8p zUC?2~#v)1@;PrS~$vt15qCoU8teD&L%Pq%N$EZFxAGBC8hc`FVXvTO(Jo_M1oy+eA z^_7k=J!_a^M?dmq@x)*>Kzt@6Y;3xou%6JGW&y8B> z%%tcqpf79fPUvikJUbYLNldGFu*~+sGnDd&gPW10*$Bj5mAH|82V>xVA~7jHbf_8) zkrU#%C=m-jZC@4f5pIxXQAfE2o*puTv=@LPB{+ngnBZA~wL)Sn@ezgnuokGZkd2Xp+j2&bAixl>UdhtWT=nGFR8N_Zz(q8aI!vtcZ;N^| z_PMlgO?9N@79w`#!HdJ_Bwwt`%JX-w>Wr?i5{#LOhtl6{jm^1C9Oaw(Ts4^QQJGmt z?>sWdDdptyT>&Adp*tjGx)@ko6sv(%+W1Jaia(KUfv12MZ&K9|@NE-I_&1qmt`**Am7|)DZk7<}0lEb(bp|OCIpv*7ETM(?Lz-@t>PU0~)D1BEIs_YYB&Z7Oh6Mb&2M1vC4_z5qw(S9VpM z(nw04WrEHJBmi=6!AM5Kz2u>-^?$<^=|x*Z=SbD^Quu;#a7yW-gd-TEj7IIvbGYQY zQ8Vby5K2EmJ!6U^gGiQ|bZ3v_9_J&t&?p*X@#E{>6R_(qR}F=yq_Ior>zBGeUfW8H~Sw?Z6`>8E+J9iP_v1=!S{< z7cL7;5UE+f;AR)G+fPuRb7yyAU!d8}<3ABR2{@VN{c~_q!3x1*kqkaMK6X6r?3z1<@E1h5xo+)q%j zQeUw}SQ(M@cv>Ykax~-it9ui1yu#%7$Qpo;opj*sy1)IXM>x}9{$ZmYTBW#%;qVqi zhZibvl2%4P>LkNfDwL&iLfuZ3WSr5XiNO嵍U=YZ;=-zZAgMtk6W|B6MQ%mLGOi!VUaes}cKdr_muGq32|u=mv-jYNgoNsOp@ zB;K72fu=13R*PL^aT$!#sVQGJ?f~HB!%Ib9tE@^1WK1dYJ7R5DOp{+4-+E-}$8FT@ zyL1$dsV3Yi*RoD%Pd%6nB|H~}^YKF^EW{ZID$iO!8{-Fgd>S0m`RThtkJ*neX0c2s zx0$b4PKdu^^5G{7j1+45W96FJBE!-2{^S&n0M8pY4s8ecvB0N^Bls;;h>z)k{)uRawEo87!8`(;s^s+}iCoVW8wG*9`O#GKq~&;Izsux{YH0QP!kVr1Q!N&Nz2#>9mxer6DN6DN zGDzbyF)74=YyIaGP6tt1vI`p*!QDtLNG-MsPdc6Ecg7u)wU1|@&WKa21K;=A+r8_5 z&npWTw=nKJZ|Gaz$y7WU5VP}(G$XJJs>QyrHCo$Gq|^xaitx0K#@)d7=JWe6gRp)m zvty%aMlK3*m#oUd3*z|xQF#iTfiuLjJ7Mzzv%|NHHnaw^^{jFkm<$n$rSA=Rvr2(O zs>LK+>Tubw@yD|{m<4WvGSt@qwCkOmVH}d4b!!o^ITEh-K@1ASIGXnZ+ztGnSm!K~ zl5KyY-(mlgA4rd?Ww?I~5&M6Jq$#ojbrF0GwXpxXaHhNn5X*Cxg%@EJ6a{-GX9crl z(l|PGg)@aIFLJc#npiv2G~|7A@x4*PNsLBfh(@A-ucULvhH*+$rc087sqQEYi7qbQ z?_;fOCA-`DW4$QHu^P0&y1V5w+k3L*G533}^W^z1;~I$elK~dA`Et3w=`T+2n5FDrp(1XtkIn(K3nBNyxff9Qa@)? z%r8vJ9TxBDw=#)cqqC^rUB)D;J8m5X2AnN;L>^j?vhlYgb4qNPISaPn-WixPS}-qg zk=FN1!&tym)rREl6UH&w=$fwsvwTNam-I+To1OylH}9TSBePl`TOdh?LX6%TN#@S2 zX@VwxvgEsCFLNwg*C~QFsdlxb#4>UnDZxEXR*u(vMxX%?-yq`oi>n}brS&$L|; zLnoEiNEBQ-8ta?fIn7&P+!)fK(MIh6w2C?Et^yMTEbU%v6d5e}G@EMsDUisl1rZrV z`Q%*w#seI%;fZTQGDdd5rz5f4sOICMFL3}7jUOru7xiBu?BWaMY8|X~`DR|Gcp@?B zBa=iqwyq8>#B!NeN8M1?FZB_0_B_gs6BPV(%(Wm8wU_iUcB^fWgd%l(5%tT17ckZ4 z<;zc`A`J*^2nBL9p14lH0qBg;+tH&Id!2{`0_iT;&nzh7BU_!oTMHvJv0SpgRnC?V zQdOPCB=Dm})O%9k#{Jk)V{#R~GLVO)_heQ}3C3TG| zS_XXN+kyg{>!a9?3!%HrH+lI&-_P{;AN`Y55{=2)^J8oo6lsO$X(g&o*moCWJnC{= zGwBDgNsqo6{yoOPspolVo|~W8=ErGe_}j#Y`&v8p+Y&#^V4N|-)JZsJ*b*`corF_L zLZR7D?+{dekFL84kSLFR(j5QZLCd)*rqDf08O}UAFCmoH5Mte`C7W_RSA#J?#0n;A z$mFGu;yKrhTgrB@`?l@fA?>Gmh)*#lqP?5w6n6@xXUs7UMEMDc-SS(aJ}2QwYW@$g za}Awh4fzktI8`i;;>TP)YPah03B3C32sJ|`Y@Jgd2Hp-3xi?eG9CTr&XhTugGGB?e z|B0^?#M-`>=6X4ipu{7xXB__)^AVatw})zt>oi0$ttymFMqiz(H3?l2Qy8&<27x z+M!q*J`4KJHZ60GC7t>k;*^qayQ&-Kk-x>CCfq(5rXH>L+GlmSUprYNbZpx;I(G8+^M3zPjj_hsXZxUP zuA0|W{mWH3HIR@vKKIu^kvwhQ9r2(5h@gKk+0gVDf<>%{;?D=He$MTQtL?y?QGa_`0J}F>T%-%%h~vZ*z=b|*<5}j2jv=B~`7-~&DU@q-mHO3Msxi|a z+@LHR>!iLVD|3rYZLaH!Girz9?=(0x-2lqN9bc9#nmQXL{3-4SlfJJ&I zU+IB_YDoFFUBTy3td2G9DAo*jt&QT|=jf{x(92#KoJMj~vt$ts_NgUlD35D&G&|mO zv+gwY(SvKpBAa+OledW_ro(k<^e6Uu=0Z~iNRNLz6{iggne3B9s9(But{CK(Sw%~D z3IPL+zurXdn&S=CL)#Lqru@_rz(sfX7P?qeR_=Q~Yw3C^K2jj6RyfyOblBh=+OajV zZ(eSGZwME!rs0oO5@3>+U+ptUk;QVIYda?(lLo6UFtdXHIggVh@CW2+{u)f51`9fE zC&+d1DdJMQTkEW5MP&@$QO8+PA}3u~L6XoLXUX)> zn6A0BdsvtlV3G7^px-P3a1@JwsJZD1bUh7OZv>K+m?L-C&gl%KCxe(@7B2H5@|hJy zFPU8IKq1|JJOKSj84^muaA~S1W_!zEduI`3i4s)GKwzQ|X*uEVxegulFqBK~R1>t%rTGqgY^?8O&aSSEP4{KvEK>r@%@ z2zib>Y$CqD{A1dVRh4 za=PPtt}W_yhvY{-(?5}}_*%mr_Cr_zt#MX;qA_s{T-n$Ppk;V_ggTv2T5jQsJbV2j z{O-b7?Bc{$Tjq0weZNHS+>P!rb|=3@Kl*iq$;2=H1z#Z+;Q z`oXy<$+1NN@W2t08b#hS)>ncR=^)1w^$6wbf<0^eYtLaWILs<#6#HmZwgcZqGA@vp z@?t@R;{h2YU$CnCX3Bj z%uHo)vApBwbC4z=a4U+!$&ni8GbO z!y8+xhcJadT#9=F#PmKIYcGB>v-xbxH*Ifj0(Q{K zoBdh1rAn_Fez#hA+3!&(fH^NOYk)ieDEZ{{W+_r6v<$snvUNtllOxmrhy@5wELelC zl{t=v|PQa*DnQM3ex>m#f-U;N3^u`Zpq!}*{b?pB%!OEyZj3j$a z9RQ@S&T_|3Z!V=ecaHpSB-SJjiuHwG5y!{=YnHRs) z&BL=xOhW(UaCKUaU)yera$PGurH!O`TmWmbqLhFMNeeSMvmx2Xp@V(dDcN^a^QC_8 ziE!Ng=78xN#|^@Bb`pujb1^~&{VH`Bh{Mm&XZJjpw+EOQozXdzN zsBkCGl|g_4={mX$jyO%~)xZT4Y08L}rh!IRrF)KmHC~l(j`&n@Th_Yj&>CJ8ExKog9a}K*KJf~KbL@1txe4Trm0tZu%TGmy|jWOI4xGKnw1 zuM}GiGLZD+8vD;sXo*ioX>>LF$l!UiGiD*zgjO;{^+NY-T(pQ#b{!2bS>HKvqhIulB|1Y7o) z2$z*~Aze{6z;a=1>3SUlG&BYLG! znOp^X#x^XDjA-N$#fn%Dy2^E+9Y+maYdTQH3Oi}Boe7rE1W;KH6U%lP zTJoW%D&n70S%J~o$QhC07HGab<5-F-t~+B2D)Kk4(wG>AIVKd+hSn-Yp4O5r$>+0I z+NNK5q?y-QlMx7+wqIZ3a?*+(yk8EQL*@J;kYntaUBz2FX})89ilS(`A~{3ZN=2x` ztC2=;vKU4J43fdR-gOm998eoeZjvEq;>vQhz+hcE&Aq&cF-gHQNKKmIq!dM@sIW;_ zXty7{Uzp#r0#gWS;{a8LVbu(}qq3RAjI5QBp0IMJwnuCBI5bE?qGDCCk3Y*R{#W9gK`(lD3KXAK;&vC@+57Qeg zna|k>Gcz^#iNg{U1i)o0Jq8;)Cf2A#stEO$Qzu6b6NN(4y{RuoKI1KN4}a5Q+Bv)L=5y$>!Xnnn1)YL!WEh>nbNldr%U6mhbXBFe-t_AGRDX=eRvZyyeRz6AILCQLkq26( zf53r@h4+-3D#cwkEk_R3$$=7h&bPU93SWMCRY?zO@_+E>+~hA7#)FSKh|7$pA^o{2Z7u?`~|Fvwzm!TOI1w8c88_Oju82$ zI&*K!e91=c*3FB^eruo=_o7~%JS0d5kpMAhoW`9!f_tMezkfm2?s}GM7mfZ+fU;1` zSY&ul+$+uOjb4bKaW8(j(NkJhX4V0u%)p+FCU{MoCNzIAHi2~)mSg-;N*!!4D5`@^ z`Qlid_yOP@zJ(_5u!nj z*BGkbzpI$miIu|^W)J3M8e1U)lrX=xXq@^4D*-bmuZYmW2I!eG;|hMjEW0J2;$^#$ z3t9wBN!YFp=hxzOQT~*j4JwZSS(eerh4`4^qQdWQf?-JDzmPe(m^Be9thZ8VngFRg zN+M9HA5j1X*>~V)0#l6~W)Brk#_74kFAQ(6;^9DVy+_1O1Iv><kaT0{P+rMNNC5l`a_P4EhWQdkFf(_adEK^GWA!cQmhrKG;bieE2>s`^Hl!#sH)1 z_HG_sOZ9ti-~cD%KjjM%q4~6UX~4U_B*sTIA@jzu!eD$_lio3k{^lY5PPF=2Au0Q0U(V8dT+st&jd#SHD^9SrCzL?Ma&QhT5{7_moH!* zggcE@z0_;Io(et>wSzhy&Fo$6*nk>*j=Pinkq&j%nU+SUlbV&^nJ>}$?I&g>KvaIX z4wwzdVCxMQ)>{y`LDdPoCkHtgMCNH$Zx0eNtXnh9|HxCJS%!~$d|1Cc8CBGvhojqD zi5k$2&?E{EFRMR5Y2PIvzXF*o81N6n7&jQ?mR?K z2k|gKAD~5vbbpa7=<$&@sHYMo^s!j+8;Ld+;vdL+_DHhGNMD%Px;M^5pKlFw?8$Qa zTfi;1P!Yy(RVodbsuT5l66-!v@J^cHc*vR$6g4Sqxf;`+J~(1pmHfo;ulhB4{YyD#!~;NtZ|FSTtcjlz zwQGb9Jf#+09n2*y7seCASAn(o32pBZDz!U)ivcsj$wqCm(#Fx+e53>BpVuvD6s_30 zQd-68FTh*>Z|!~)YE5YGh8^?bE+ zWl+|DH|IS7pX)vp+d7KcZSvTjeF@e*EDSP=YA? z#97>m0JWcoNnx@pX26ym&Q+%&yI+#NNtp^wMei+m0QXGjbz#x-fg<)KZ(1?>ik6GhGq$QJ`7vo4vk$WMe{IA!af>T zvKxwHB4x{5_f&0#NJs{bdw3e_VmD|OoL;pm^%cJ6Hv-BV!Qv?Wefb{Sj6G`(H`yqV zk{0>Nl9Pw-7nTY~DVLC97SuC6+;?OUvGHjG839nisRgtk15O(TmVn)Eg~{1aQ^}9z zs7&h9Ieh7H&k-n-#Chqb7cBkCKR*J09|{HY zXt$&JS^hA7Y6QHKWOF86po@xeXF@4lfu7%fBIe{-z!Bnp6Z%)*%UGHo<%pPU$>P?b zta{z;Bmk}g7LP-*u1$G-62e^lwF(NoOg%WJ{+qBCu};VY(A!Z~~u!%-9gIqui`!G~9tG#B+w3*rrjA zaj(PTv8Udb`;cvvoPwW?Gsdkzy*1AjAq z=tackkd9&BRk{yy*V2CMNY9i{B6kHaXpB*?Q7you!N+O`^|s3KofJ42IBV$tI)^A% z(DIuVL}^#I4GX7&4h!_j{-$pVXcW~jr&XgeprRsqrz?u-NorJg+G8%!)~tmHEFM;n z`X>{jW{|jBXvq2IDpxH*KOQ@pX;!oE(vA+Un-=4KbuPcd`dCd!8xb3xs4N|5oUK1d z$!OK+l0Vjq4+>HO;w;&iw<*9nTfqWA522zdGMKrCb1ZYjfSXrofpaEO;KevFh7~It zk)@bZdCdvLK(q~vp=1)GLzyyG!MlT+a>!HT+ zw_(FQJ*8m7jM3x_W#ZelRCZ?|$@sEv{Owq;vG1{ACfGpZR|2|KenuGuzSt@lK>(m~ zlMUy{K*k*=K24aNIWPy$y%W=Z#_ zNf{10oX3~48PEnp(Kz@>orVLXA%IrqUFzl+3YLA;qN8i1Hi5J~J{r%kx{YCn&N0xj z73TYV!tf-Rc|K>PO>a zHe>Q5e|(_rIsq96to$~(h3kUfe(^U@Z?kh<&W}6(omF}Kza$A^!14U+dh%|(!uiM1 z9KCZ8=PfeiAH3>By7P>@;Q+;;*0B)s52HPmA4bF!a=68eKpOeE|KyS1PmCvLv}NP4 ztmqS7162ew^C5VA_>Pb8+hJd~V_(`00&XU;{`KNO>Oha!u+0NT0wlY+WAh4N3r<8| zvLtU{iGE;9RooK&?);K{-BISiud66)>k`L7kDH1DU$YiMm&aASFTE?h-*a5=t5)}DU@dFyuWXF` z3=nv+Y}z3wYA)4VE_cZ2;ZwzlJw}6zp+}Rrb2`%G!NguJ{L8TzGwkHkOo4Z4hT?@6Zd_ zHnv&i<{thI;4!YRay&L`L>{Qwp=ZX6w-AnJY1;#KY!Kgu6P9ln3Mk!gc?R8#jgGCX zUj~3LK@mJKS44ozj`nFjY6d8)az#TEaYD9(sZK z!LWw{SCyIv!bI%i=X1nZ(TfKsC;V^58k{!V{o}iY3o?araW779{Q12@r&ok}k@vnc zS$y$QvG!K+ZUCoQ5N{`nv?LDk4&YJ}ZUT9aFAsLV5nFQh+o0cT|*r(4Sq$il67n>K4@tq$giRh*r|+(RLSpej?m1Yfv8 z%={ctt9eP(lX-)f~zN^Oe(Kk7ks|}6+@qLwi+aQa$MKCep zSxP-5+yaQveh%caLdRK{O5efXHxUQuxU3BdAQy}ZFz$UF;I%pR^Y`~<%=F25zIsp& z+Ti0U_FNn`Q}%HWI-PDeyT#|hj=#REz;nL3#Qsp=pBogkj#}}PZ1d;;`YxSErG!LV zQ`x;y+k9^ap{|5~#(%$h<`;j=DZAA;MZ?^J)CMS)0*T<&{WVb~Y!(8 zQx2cV6dFdgzSbyRJxbOpSb1P=IZ^ruld5Z|vArgRY+K1LdloWtKQlx^a&eycRR17l zWi+j_B2>gUPNw}xDFKZnq^$c(RpmUPeU%y($o?|o9@mI4sbL~CJkaPqqA}Q;z3|CZ zzz*o>-d2aNr=NsKz7{q)^gL~K0Y@KXCn!ZuP%)_6tf9oFhQmz?&D1OL6`A&Y|JE&Nx5_;PzxJjJdwl4D>S<)FwLIYo8 z4^l@A;1er}p`j;JKhGr2%>IE`Cw_;jSPA>=(l6Om>(nUu2-h!Uqfukj^gBlC5Ca6U z28&D4Lc{(7?uD8q-p~a(8wZ&~+Nf-du&iHmAoDBqyHo(0&IW?I*8%F4apDf1nc?b| zTPY1;lg8xLu+L63H7Gp}{L{DiNxVUuT!5G$AImf+sxjpgF>%Ky>Nkk@cbcZGzz>=_ z_>=OSao!c9lM0@+!#5)_^GmE^K?*Q<5F3<4v=E;)^hZokbxy12#4?ffhx)ZQ##UGX z#i~{;gK^B^j&ix7gmgU!ec)j>E+;o>Y%#x`WPUzUY`s8 z2lpAr?-K=jVpWeUjf_GbLzb>NJw6MWJk)~TE=^^~`|38>@?%^c!ZcQhv8UgYkJBGh z)*lf;=ukQjWcb5y0}?4tVIIvpv$RouaZS$tnB_=`p!9r#t5UI7^dCykvUFOa$u?@- zMn}5I&c-sfCgEhmeSuepV;ay#ZWVAHbJVmguCdlDm(q%9Rj@4{bwxwps!%|CZLe)y zS4iUnPv25kYWCcsYFAf=^Wk>XMp&rr6MS(+Y1Q#%(mb#uL3@ojNAnS9+43dUIq}V? zeQ2nCsVRDi=dXRMDGw|GYv*{CUxg37amFd0h1cU6bLi(p#o&Qjm7b z1-8e!TO^8TKqekIKVK(C8i8uh-+x-hYK?w8WcJuDrN;;?t&v!k@7HF0U$ zod`Dha=)68WzVRHd(@7I-c}Wu!<5$sHIncgIxMTv>T!p+TD*)r^=XO#!({$a_e59X zSw1bc<+(0?Z|~fcx1fr;yv$r>t%DuC-hOgmEkFa&vuDR|;^2YSF)GLc+}NDU=-dTw zH&WWc!>ygCKC6y2p+uBa!6j2q?U-G+YNM!E)*Tn-bX}!eIk6!RMFKk;Og&{}<(hQX}B zZfnU;+^x_SZro^`o*)BJ}0YaOCuuulw%sl3U%4{o1`&@PG6 zh>96Ui>jw#!OLT12+LpJFM_%zq-L^^;fXT3rf7_BR%5G;MDWK^m zn{hFYa-u9MwqaI{mh_9YK1hm#2o_pBcw<~SLZ}yn4_G&Ll{wl8!!S<$YATFr@rhc`7(Ot2X9-Wx69S$~hqlC?M&(c4&;HJC9r+irx*}(-Bx|FA7 z63cKxV`Cin_5vZ-kS;QJF1b-$qMN`0YR}D+R0>rW`ObACJM?&yawVE$f12c$zMBm7 z8LqnpOM2Q{gxk(417e4upP!n=A7`To@pl!K{gEt}`MM<3~S^qpoa8nM2y^l>1pUKE30_LO3) zmp>~|KKly9{Y*}}3!JdRl-0gu8JGM8YpK(-VP+}gHGL_cph7E85lU`+yr1$MI}+|- zQTUr0;2UZ*X3=C{>_TxA@_GSepCmp*E55C7SCl=8O(1Efk+@K zkATon9Sa@}r_E_pRl>T`U9=uuj_=(csJ%V|B-v6X4-bzrt0?s;AkY*oyN+?0s4Ycf=XjtFp&Lt)=e zB7oTw^3^Hu-CUX8ccbX7n&TAL959)veU=20xZ|U?8jU?xqqzbI{2qJ-W`ye);^7np zK-`f!ts8x&8=F9L45s8w=sID=p*vJ#s2tqo#cZx^xaS_~uq;KKW$yvZ5G&G_J&cMJG*Z@V>UQ!dMW4N44G>qWXg9gWp^Av>u>SqQhC7 zLFr4&dT9={X1{~%43eK{uYgMVOnT%4Y|=KD5q;C$yn%lUFK@}ZM5@F_^f}qaC(Cp# z?V7;z9izjuB!7DF5*iYIN;I6DPNr``b>14l^xEl-avP5mD175Gsvm%Vh86O)4Q>za z3}OzapDABRiTz+QLOP<0Ta6J=qI)S9S<<#bI8z+lhn>fu|nY zrTYJgsN*Y_SA_+&4mhynOR7u(Y_aYfCa$+X!ayt=t5Jr@6RfU}v08cMV)c>br(T@< zYME&t=~0FR%S|zmp?~I zvuBERxgCZg>YJs4i_{ngQSmTuz)P$UJ`F zV@NeIt}XF~KJilH48@uoETH6Y^fCsmmTNxK2C+prZIED{IXwgbE45<B!|FEDh-&nES)7<=aR+@KVZ?k*;rZ6Ryrsaw3E0ZHsQu?ffqDx3<- ziGn^Kr19{52R2%5+QG~&Et?yOd}=(JNn%`@S)*I}a-0!1wc3BpLJ&x^`6!fUJk#VeL1u+X66s9op zj7lwCfhpcuJThMFO+2bZ5vgZ94#GaqG9u#sJn`qRusqQzJOacFD&v8mSF&2YYBQV+{jut>{|JJ>0()ARO|sOW>S;KPDDBO# z+Pr0XH|0g0(x2K!9JZ#a?iP}=lO^>>SNIB4XKVgNiZYCA330r6YWr|3Vuoc6fi00d zJ{;z=aO9R`R}pLM?Ke~uJRy~U7>%)ca5XaDIk#We!K2p5!mA?1pHUeJ~~FgVZMuRUvMy~UYmMPSs4`np87Fb z>gMnL+-X?q5;*}{0J(cOC}9dYvyDFZ7vE7baC9tlZzSfi&Nr}FRI9LkO?T+c7@GtU4;gLRLw~9vS_AIKCGJlh#v$@0vYe3M+>Pv9xO>oUha1ZW3M_@U< zlJPsE{W=B55|n#+9x7aIMU<=@dV4U#`T<(u_sr_AYn^&iz*?}s|hk^-AbMokn zF-IiZ^d^2M>J7mp@%#@EcVMd~YD&cNaGE7R?Dga62Rb*D-G4YZsC7BhllTXoyrAnE z!mjNG!YZ4Z-X(=XKRa}&lUQy6#lig_0RjP>qZL=jql>TVoxo@fU~6m7 zmd;j__vWr)gJCP-jk~3cQV~2>PG;O9F>mvX{tjML%MUfBsj{rga z{|OKdF;mRU-%7?V6;B5qJB$qKBl-PHEmu+olVtY_b+y!ElUU|`b$5fT=6w-|{6)%r ze|L=v=u>WRxQT7dhD5Jl?N^U^8SN*R{?i@nHSWGPPyr^;f*F2%)W%H=5b#ZGT)W)HhPdC3OJb&odn_|6CG$rvh^+iJ0oz z)0itN^SDbdSPLV6o6*wPnucWm?(-cWXn@ck;-+|o3l1DBy*V>;|7I7~x{?L&Q&0WL z<40c$z)E9_t2S|6KDmeGXv<7`rf*~n=h-cgf8ibY-9JCnh5lyws)1l~fo0Ij)`5p;Y;nV#NHoe)Djuy8 zwfXv|piLNFNL$*C7-oI|=YGBs3A{YCPd%7QvgOkad#hqx&t|pGfYH)*2DTGn8zXIo zbxNLps-)N^%vOB5wH0x#djbJ|K_9lXc4yov+6{t;v(%gZT4Gc&Y4!~UW<>(=k zSEYbn=)zcG6=%hr6mfd$Q#8ERona{k)|xuMuYrx9C${x&xURs)OCY|ZT-zzc9;p~> z1)*0j+`FPA)w^6_?wUjDww(Icam+r5j9I-gqqg~b@R!^yw2tba$4K@;fU7Mi7 z_!=UFAEkH`OV4hZecT#*29w<)hs)5HC$AWK<85Da|FDJEzzv7(0aOdoO)zT zl#BAY16*eAgiG;$UmmPK%>E|AZmXMIu!xF0ad;sqq}xcr^+s4;1ZT@W`T`FDLKyBO z)pDF-{}$dQ`~2O=9)SI8nn@b+93gOIlD6e?_5_Aq^h{fvI4Mb_Xh7aQQB+j-52Bkxj z1D!*x&0sd|ZCJMHM)M=m7pF#w(>v8s@ll8)N~5MjI6-lqA%Ivwt5-cl${SXkd${GO zEa(x5w8B2Lh!9+AB_l7u&hEkdE4b+tMN&cHadv(*@UiLfVGAgKvwl?g(Wx^e=o2;F zn9j`UPOZi-?ZzEFAl@L~z*LW)nu@&-_H^AV(j!;=u3@^dRz~Mk^(pO)W$FWKHIoPS ztW5Y3jB=Cx9bm>z#LbAy{#Bh+1^}lf+`GVTopSxN(oq|BrlBRwA{Pn%4I1r3N}a{A zjF&+xyo3y|C{<%M-fD_^R(EnVZi#x4?yjp5=EhgGp$*XxC(2^KW`a+@&dzwDNh6*j^uI-yA1BtLyDXWLAv8hi#gEd$06b;i!T&fTdz0`yJ4(<+bD>Zu;PpxCl;#t*loL!-d}OKy#+Bhw3qkay5G#&!u^&SF&{x6YwU zR;_QwMQ7+ox7e=nsz0uN#-(7$H?SivKw>@Q&rd|vhQNZy>uFIptQaj-!5B3Oej($6 zm-yKWZPi(Bq~JSdH`x0W$Ut%yT89e!T%WVJ1eh1sJLuzWAXwnNfKjj)p&LxkmCf6QIVxrSl+%3Duk94Q~gORhu~<0LTxs zPrBgbp0P@;O)m{bE4!@abm%_CJuhU#7tpBQSiJY{-{s&Vts2D#zcs_n&p>wyBQ@b% zS?FF)=P~r=xn;7fnaM6h{S@ht;hSJ?x@&yI{-DSF>d>UHypGS6ur2r6W;kk+yx_Rq zY{N`W5wRBgW>evwqE=5P^tm~Y1oVS0TV#De9->n?VI2PRP`C3^sYlXQwhf~PXdIHD zG1W$BopyvR@!0%K;gGRKoaAegu8>gf4KG(|i8q%0Uigi6jaHID(P^9U7EJ8=PtC*M zgmakn24dHeEOOX%IDgAkl7x$ZG7LPXG8|}P;=)I2a2D?rEhph7o>;gR0=SZ%Z9jLC z<2e+0`J8Hzi`pXL1{wFPfh`Jtw`T~nA|8*@5h+D}$FI?~E(w+~sG?^d67Ws$+a3F> z=+bv;LG0W{6*Fs3979FebW!jsg{GP{1347(Bq>&SN~9 zI|&vA$-t)#^Bww0McOB0MJx(OiQ0YieopK&n?|^9*?dB-iEo{J1ng*SI(^$6P<6dM zUkEUKIe=3eW3hE^0Ur!oVOIAt>8hqNrseQp(@_^diYZ4E{gHq1Jj^V?VQIU83TG-8 zf%1h3HyNw(dgI=<2o(+8j5iM$tE!rEXzYQ5awUJVU)-wMh|ODG`TaYB7rTm{2xwyr za&+X8XS~AwfK!od1Q6f`LH-$JLmqd%;QWyeSs#O|<>x`nQhj<1w_E^5?U({-09^S; z1OG=)E|yq3Tqyt%bKV%m7-Wh2uMbmgG+C^y(wCQZCRTdk$XHV|?>k+{W?o5(Me(hT zCA!{?ElQnn6rqV7t8y_LWK{DFEBLI^X+N1BA^k$>P(STa9)RwP$T_zY<&JveeZVc3 zen3PkW;1Smb!x>t|&Z(3XoaaW^g+p|9 zQ4Z)Sb#8dn7}|A4u|gp1+%v!acJtM1SP9y!Kb)Kc`@i6{v{g#fRZME(`EbV=C%yObc!Sr!U$Ps9XBBmvpHB8zbqWP^eHdqrag3n|M`GryEdVxd# zs+hK3paX^>1wz~wJUEGCJ0b3iu{+#A?4S-|(AwxP9sqQ28&I@IeHmCU&Vtp@6HOh9 zYwyN4jMwZ^Tu#x|%|o(`t51=N=`wu*BY3Bu5o6cY%@D@VzlUYm58jw>lCL$gExA0) zM@G-=uWxg@EBb|Tk}tGpf#5Hd$9jvXx_L~WeW{CZws-brrSo2wN6A9mEW(gKHiJ9l zmr;)uK>(z<3I#&mb5N4rof&5Wg)4@Iq_TWvR;A*y->qebFh1l?#haHAa9%|sz0bq| z+w$+#LH$v&S+c$S-EN|mb|6^R1+!mVz<;KlZ3k~ptit@V1AAYrGK4*-l2ytT3(?6Q z@Fh`T_!NyF1`c9T%ii{CqtK{?I{SUmUO z?NFH?d!rJLpEj?x`x5cEtsmsF;jN-%@g`WgW}mfBx?MCdv~|W~5`|UlAa|!{9g9Z~ zye`hbw(sW8a@>^yVKw$A88bRw>RMeXe+W{$x8)V2P?c zPS-m`e%DF@cCTw5W&&5rtemj5cXR0cC{rEp9@)PYQf~;u_lnyXn=#LQEM8AR8nuu# z&Qrv7Xhx5VCmJHOkJhAhjy&1)C`a*|RRFP08>FK`+$5uV z4}0RICF0xH2&BfUd64KMaZWu=0}7#p4TGS#jLDUAxzBPpVpTBrmt2gkl;{{~o1Hm) zaK*%zer4i_wr|pQ`W0?6zNu4oq67Y!@qZV9$NMKKk!z#;#{M<*gL>VcVo4kM3^DK2 zOjXNG+ln#FcK`C8COS*Kf&JV=&$C=19zZNnJe zUu^c!x^B!A-wl%Jv~FhbwSLv4i8Zw*_<{>bU1s+t^nGNdkE1`7>Zv-Zp8M5O-AW}s z7~rlzYJA*9S5VbjW#0G*cDYFer%sE55d789bPx-X5DGt85Y&ko-q=I>H^S>LT;L3vVoCAXY%Hjd; zeJ(QxR8VSiKDRbgK{&ZQHkQT^POLTAZ}atHqYx3l?1={b0gdCpE?|-aXDRb&^N#B9 zCp8oe#hRvxVJN$&Dfhax)h>&gsLH2M6t*e1#zh8IJf0c`b@fFM53y&S$gXWE%Nsvl zOCFjs;#I*Ogbmy1rd1*k*j)3hd4{vPq%J$Hd8NkAKs#x~4@fG|Na6C;Px z6~g@|;(t1dR6x=x3NjGTBGLc!E0-t+fX#m(meAdTeqrilWkDoGUG&X?#w}KOHj2=p z;9`Aac=292kFe{3oi*5zf5yU37`Dg=!hew_Bu_ziaP-3UDLGf>&p8j>r?N9sFS`bP zz?y?7gY*0Q5BEAPGckPo|Jq0Et8&=N|D~F&J@ZZfOEvMP!uvxi6a`OUB@Ph5@=-bv zzj#ORAZf^lNIkh1Y$^$}s;#Nr(kof3_no*Uixm-G+S_3Mf|+gPBNpCllH@}&677&= zWUOUKWmCZ`zkTn=T3{1^hQAwg2OBIV)b2$87i;o<84no%^;GGgMWQ-4{i}NtvHiwz zb|G)YBLtcD%nXZ9g%D*wqZ@Do1?~sO_YpyF4ADA0=d=5K^$INFVbs+=98ZJR#yn<7 z1rNRsw}5pff#}?P@QN|0S>S!56Lri_lgOhXaxI4j`w!~L39gKL1h(G#`FXE{TW#E+MAr1OTKhR_Q>^M? z*Ezz6y^vt+Mm6qW1(Y_10aS3Viw2NTe{Yw@b5{j#%iKRier)f!5}5ErdfrdqgDQDU z_rUk##BuZLQ@XSiI`1~Y38cjW2N$1AwUdIO!==XxaL6oCxb)^V!$+A@f=L}ujgPrQ zwJH~wjdNBTGRes$gV(8pe6EW?_i|xE=fC(|q6fg^2t{+eisgADbE?w0M+N{@`LLo4 z&RkEB|NV26wIC7yWhra_U+7o;FT|ugO{gTiSV{nP)f%PZ^OE#pehpG36p1GaTF6dY zmQ~}mIjZ}jC(_41<&^SI5aOOl1nPWH(=WnZhrSFmfxaC9AVUxzIDPi4kF$u`5Z!`^ zx8zB1Lgx&NkcC3k4(LW+@dFGtm@wB27|b!W1eqvpbL&^m#1w*t^!M7uSkx%NUvK}4!d)Bo4ina4xb{c(I08Zkzbv9Dt{ks15G z6Ov>}_AQ}Ek}ZsNYza+vGWJr*mXz!vvWrQ!XKYy_Q)r6#P2*RddFJ)Hf8F=%^F8O> zx#ym9?mgensM?)k*PA zVV7XB^gGM`qlmM{Y;|enA9c_9X5^r3zbS~pvonU_whrHS%UN?yd6afx5M{aLp67Z} z*g9t45EtNcuB&d_yGBhO_gWsiKQi*0w;Zoj2mn1kI}BP6W=@>Xsv=aKEq^QTtRCIM z_C9y5L8;j*Gne1HVZm>FG(p(p`m5|YANf$+^JO81b%UHseXx?p*rQ@-3VND6hATa`^K~k%kBv>x^n`1UULQQ@q-CpbZpJv3ev-YCz&~7m5sUQWm_BE{ zMo8T`p)*(lyMti}HJy#zFxW@0!($VD4pWGwg8ot%7@R?6<8v2?FcI4d3+QY9B5rXhpHQ<7K1nIR%sNX~1)Pj0 z0etN=Ot_r^kvnKSF?Zs%#8I$wW@XOHeoy-tLsO=U2pDF?_LGgGBx8mVOgweeZxB34egjnFnimyT3M%7{jKm&<;CM_Fs0jC zH)CEn#~mjKhB2EVm*vzf5mA@WIF;sq)=CFi^OfadO?>pNuROvD8>dkVyY=c;hU-46 zH~LilIs#7|@=acAf%u}nTBmoXo@q%@>?EMHrrHw(+deUrg(S*Fm?CQChf zC6&o8R$rc`;;Jc;#o6AYdCI=U%pr|sz(UE3wztjN;6yY&#H?!GrtRTZmaewx`r6H} z>DM!LUn;vp&~COk|DqhIx9-BV6s48V15UL%0)7(F!_AXUQZ^_kl#6yMVbYAnsdTSs z|5!D47UyIbSH3HN7P=dMU$vAtwQ=`2_Y7pb%wvBze1ZCD;L1vMZNci>;h%OJk7?Fv zAs;hD;<~@;*J{V{&?YmwED3@(j0(|aCRi2ynHsOnPv6Fs>}NV1!@^{;Ox~Y(ihs&6 zJ>Gd{?;N+Q%Jvb|NrrFTti*6ULU?(OLp!VhoufJ;+pFrmn^pK_-3RpTNB3P0wO=m@ z`p#IF`^EXGh3MU$kbE0!^DT;7^pOMdSb31*+40kpteF*11xz34dtU8W$O+5#z>#!s{L7c04ZO`_sk? zYq!$=K1)+x|1{{Z8v)yZ54QXiHFfea=F_sCCuKP$0UNK*20w*znO-$?~p z%8z08G2$pV-x-T}rX4_u7QLm$eUUWkj@rnGl$4SeUf_y=zNzPe96$KNXdT?0U1sUk z!+CoYCK^UAFiy<7`G4BanaUggwYAe_0(jd-8)3rA`eX9gax}|8rm&IGg(|_mGrEn| zX6Bw^uhPp`knKTHkM3kML_s}E6QGtV(yJC`*5an9QCH~;PjkzMtqt*tu8KKX4sWqz zZ(oKyswh`*vUK-kj6Dm7bGV=j({9wYGMvP<{g}MwCYN#VlJ-wFfEJ(C;HqA#>P2%s zzEVz7?}4rE8U953bTl(H?8*el4lcIvYUpDPZwPf;S*_-X_-N1a6voW5c6ZQ7BLzBw zypK^r;Z6Y8YRx%nhHw8^yIlNnZZ**HNNOG`>p08Ijk0ynb8%|)MA>nuQgfx%!;5sp z8Mv(S$?w;^fEQJ8Ni{DF)0S@~0>*`=^70Q`nDsut1@oDwl&_oUA?=F zavrE`Eew*KO}ze1JBhy*Ww(>sNiXHLr$3v>`&Fh16yn7<`*N4qMxY_2WA%xl?w4At ze%%Er`nObl*iG*kD*_e8%7dC$6?z;kBjwUn6cm(xG76;H7{}?Y%Q)rUJ~^4g?1C3Q z`y-oa=lHIO78m4PB+w7fi9mZ1QUY9qT~QEJkwg182S;LC9*mrA$p34-NFCNp=pf z4S_xg{s+N|qP=QgKVtPdgSZyCPJVM+lQzP$P8x-rr#(oo!3+QbNme7jl$kIx8LFsK zg9d&bzJjzDoub2~22rn{sF+)8z`ew%2brNSx&`fCjVCqaq{oIQfX|1hYj(UKYS5&4 zL7@W#uCNXDudIZA?_oi&6+&)q?{wDAR9C>2^df-Z%{A20(wHZa*_ZIz zHV)pVCtTyURsQ_30dDEkT#c?p3EfSryEB?FoNq$s%K~@BZ0-fLL6dt^jms_lD=O{! z0{Wqj412})GQ%M`g}&%G?|F!#cK<^agxlMgBk~aNtGTf#6}wtL?Z^zLX)08Z`MHPH z=|-Q~lv+m8qlJ+cQhvj=%-+b!535Ve61v6=XZx=^W||H8=Jnr@g{tzq4!aw!T3dD_ zYPb5iOkNb3$#5~!xEB?QjZHDYt*@?)CKq{~jw*ZZbscb1Y5+{9skLWv9kRGHbjJ}u zH7P}(RF44gy2U?>p&}w|LL^XbK=UqsCo!6Cuz2ZZrHW%m>*?fosu-MRHAWZC3cUuv zGFL_5FOZXtK8iIlj_9VY^X%C+6T$FJ5eZW~i0V|=+L6jgfTB0(EiLX;#!S#8%y|$4hItGk24PEi>cm@j z)Z==c+~F@XU}bF+_hG>6wejb(b@`E2>s_Ox~Xi8VUDSf_#_lvJS2?Kf}q?!@)F zCm-uu<}1)C8O73@M>ruaO~W4>>eUhNn?b_)`%%(oBRH8sP67#SL{WS%`;7{@AjLTC zBVn0!S|!vr;L@PhwF@RGSv_qPg>I&q7OP!s&QrmrebKU0#}gTuC7(C?l<3FSpdWyr zS$S71ZQ#4J$G%wFpxQ>>f2e+yn(_2!j+?`m_p60_n!&UKKW`ivKUR~pn+e8G# z{Y;o>G5L9etBj4s{Ch6X)AO~!cMGqhS>dY1llN?wjKXp|-@Uh3oq|B@g{o-~waM@e z@ppIAa0E2(Gkl*fx2J{R>%PHE%Ue~KXP_B<$_i#?)!lxAuk}$=r_F4d-CUaQ`KZZwI4}*f+C>#^>t*Gp=ea4c`jBJ|xCgwvCTIpPj|epp>UR$Eq&u6SJ)& z-}rK=)Yy9S$ts4vL3it+U*qQd%IU?}dVDY6nb=E(8Q9pSr5FtdO@f+_Ua<0@Xhd9x z?azIXng@2n!Gka~=ptBR zbYZNMiZh5ad7|>#opA+U9;2O#e*$}UIE&4j%T(Ee#nmD{k@nQ!SA+~DA|*p&ik3PV zuW+>p^brtlfT= z#VU}d%q(=RYDvL9emwA9=eUu~gGR&P8iU(v%k_FujyQu&;GzkA&X*D9#ymsX?*()q znTo`{Xx)OFa%xRz4H>_?LGk&Cu#47K)s3ctxhq@I{V1WvA=jl5|DIs3d+ur z8ql2|Ixb(49eP_jJgm>z>LetWmc|koiR;GV#Fpt&mu3XhYwYz>k+EsCfqP_&6xwH| zIfbOBm<|Zjn|F!MbKsqI8UO%ziIIGu#dUIl?;(sdW%MB(2mmRa)ZZTy7-O2xg^>jF zVUqKixc2Od7L!QN!wFK-QDF)&n~4lEq=s zp(4TGLiYs8I$VGKdr75Npa{4oa=_7Istd%q4y{`MTf<*se>fL0D7g}UeL+c@ND9sP zQ{+m-6pC~GHgWVL0bQgfQ>TD3CCSiwp*+{W%+CJP_>l;VW|1cMU%MgTf3Hrr901cQ zEA_iT++?#H(uICPI!;l;E$WUm}8ULZ^Fs=stWX*r)P~)edZ=e4|12LRsywv23TWV!2N1s?9 zoH)p`#!Z$v+}8vEh*QWc+y0@YxDUSt1OQ+ZRQJvQ2YV>W{rAf?QiDiPP>bPzC?Mu$ zFef?k;rc89z(+yXM*g9XVKyJ~av!d$0RX%dB=`LTG9i)tx8?r3ibN^_<(=N2en4{F zR delta 35498 zcmYJZV|d(c_x+v5Y}`!T*tTukwrxx}vDLV-Z8x@UyRrTBx_`(2c;3&e*?X_c5Y~Jq5mmz5jvSG{Od97tU`OPXP+>qbcF10PeF@RU@oSZs(ke|NfDZC$1zMoE*m!hNL}YBz=hV=S$^_F>V3I{gY7)1T%b*Lnx@~#5gFTC5KedGdUJTX`UrW%qQmhgJV$(hO{xU6zNTUMJgGt`)XFRm@RQ=cW0O63)px9 z;W6Pcj?_6R)iXc7PLZHvXf9lxCU;IuUoEn+7PZn!p71Su^>H(1%oTHI2pI#Y^n%nF zrIpLvVe$LElL_uuzCpj$2*lzn;+OhC{P&-M_d#wGe**(^OsWgQPs(B&g>L1xAip@(u6SRMdf$&AxlQ>rKGo|OOIl7tgU}z1gTEiyns9@?Rpsx zUsCN~HX1|iSCkDN@xnUHQ+}yuD%HWGA@;BPrk%5U(D^lW(?u%^2?XyA0>cB08gU@B z^BNz9c`X-2C1VuZ9GdWUK{hp+q$y?YnyGdKkdRWD#Eib!cZ|`lMAk<4s6nK3+cs<* zrYnXgJrsJ_TNJ&zX~t^MyUPCAc^qj58cZRw@bKc2H>zszL&_t@qJv`e`Y_4EjQt5b#Yh=XD_#a`_O@8utwrd9tdbS3cL zF4~$_%h`j2fi)xrq8k7&L=6MuMLaKW8AxW(Q!d^P)Z0(NHp2FUegjj&fYC(s?}mzg}(-{(!?H=ElbA ztMdcc>@N_kaAiPh9MT}nXQbu*1YF5^WLu#(Y0sdrpsWsF)+(T$(M6b?0Bh>m27=hA zC1>$8ZZYm~DINWk4niDk1$D{0_xznD$;ROkFI}jsE>(zgkw^!OaAI!_++~3uw`y0VJXju zGlqyX2_TqiG;kcoCgiNTzz@^!S=~O2?76x>N97>yG?{wGgV-PV4%0FZwH)fDw0D@; z2$4gBn0@1K55bSY_rq$0Gd{SIxQROYe$55{~Ck4buZxw$6j64YDEFEdJ;Cvc* zg39!R*fxwu!k-fMXvVBwg~f?B4S3vGoV#u#mEUX6K(o#`9*!Il>%WWv=ioDjjHIo0 z29^OaYdN*V)&Z==Oa=S=1d1Y6b2KI+L&Qs&{&J-nokwsJDk@g@OW5N31O-|GlWv8U z6SLN6aAx-ja+uBN6eActMh7%|4#}T1_=+G~#4{P+%jY4-rv1z!NVxjd++Qp7cqWC< z&5l9m2HISAtltymnr^rMOnxt$8O@-wMc)TJMQ$^_9Yq%;c?29u)mLJ6BS-Z7w{A9- zFzHD`KkR`D^M)Ay`hxIHlvp?Z*qGS1H0U#2zc~Kr5dU_PW|n2WYEP@08=rIk&GgD5 zd*2`+h>l5eb2~XeXvV8&0Vy`||Zc@E)^4JPl=KENu}M`yBlQTSYNfDj=;H&*)xi3VR%qpL*Nw<26fRh6HF&DA zfa4Cznu`+h$!K!C=>Y4@Gf1zgb#e2GYR5rzAvP;!iS!7|BnG6@-{X)hni4hDbDIL0 zM!ZUn&h|2l;6c&*sqdy>VVU=Z>tYHz8}6Ofr##WSj`iG>Ay4 z^*r?xF<}l7Ukl%(;9rlUCqfJ&v|}+L5QspZ>~;o+GbziEJPKCbJ0uprV4oB%{Yfg$ zkp2tKa1unl%w{q?6H9u8Tq8riS)D-%;U_A&Oi_6GpXB?Tug!ufzjrX8!OfjOHzNb~Gut!iB8VLi0?%}7E-oBYlEjPh_=Qa2Rn!<(MD4)9 zr51V3?Z#-xlXuduDxT9+o7CEmia57f$z4j3<88BeM^Ii(LXswl_arGqT`i6Ajf%c+ zD^fbrSkD`?%y5!;$Y9y!zD@`b_@-|88$MfI71ds(Ete$zqprErQU^7m@Q|#@FSu2f zMPWX0R5mNUQ5EXRZ+mc41JYx*C2Gv+3;>6J7aD!b}1VBgk-WY~9K~#@-P$PRD~jzEW;} zt+_U1vZ>Bq0nE_O;=NF_3tLb7%T%S*z(Eo4mRMNPt}7g1bXPfq`S*xrB$YzG9h^pl z)<4kSH~2f~r5ge?v;FIpy-+VL2UVT%rg}yiakOv^)eu|TB1|1EOOQPu$Lo@jHiA%9 zVnK6jF-Eh*uENA=(j+C%7)eS*Cg@E=Rp==Pay5sYc*OB~eZOHHLo zF7bIs=Itf!{INBD>nX17qKcv(Aktgk%@uRa5$$iUppur?m|Al6m@z@|AeVRr7ECn| z-{wi3n6GSEQS)ZH3?~zZj+LHKa;@XcvQ<-w08^_l+8M0l9R?kA&Gx-5Ol*+c(I^TN z)u^^LR7akWFkz{d6YCI`;mJL6`;F?XJL@;lMX~k}b=s$cC1z$@@q?cRvi@8y-0}TQ$=kwHMlq} zWE+41IZYvxmVx-ZJmoF>KNL(`~l|g1~ z5WOq%9GweJ7aAf{JFLwQ^A|-b+y*pfAR^YAD7T)8m}-=?I=GnyXWkKPGE&KpmY_sc z19w`ZkjSXS(b~K@M@pqk-`{D{uoGgkPL^ED;$wn+xhM_xJ1f{qP!y>W9*?MlE^ z>LwJI*gI_wx?)ZVJiS2LH8GRW*Vce+yc6Qv*)^JeF{dnAmQL6_l{aP$T-P$THA{2K zG?HTGsOS9gO;x?{b*nE=_&&H85J@e#C7Dvp!i&Ma9Gw$;V9_(JwZ&@*hlqO{3*ms1 z|0lU$!jg{cmW02sz>nibL-5V}KM7(x?hJrqn>T@KfKX#oe@g?qAZ2e($bjWAj5g&FJIJwdX9s{Lx-OgCs zl?$896?SNJvm-S^laQ7hePhpya}tJ6U|idyrsfaGiuWq)YQ#!9&a#+%alN zC5KMhx-rI;Up4t6x=rF0fCU1WIl^bEnt#&WJCcP_V#%C40(2|$Ynll1-s)<0xWz5{ z9`co!m3K(N)d=Nz;1iW+mAW)fyP4hsN$*b6Y1u9_hurjj<;QX0NvcMz_&_45aZo_u z7y9CG6sdl2rco^ z?T<*1JH54VCQ65#o?o7DUcQ0HP4owNyzQ?K-$3%IZ=rF*onJoOAR`%%>wDQRu`wQ7LJggaQPzTwg5L@Za^j3b@SLfCd9I{tou<8bUx~fWg8hRgj`3eG;hv;dK%MukHcl=IBhAg_>G)NJ={f67_ylV?thzOHBUcC# z*Mc*{dIW%@YR0Fa99&Ca1^y*uexVy(|Dj%gEUWf|_hRKHQROz^mWOYcNTPN;h8jP# z(UvO2K_;rxZx;q5>OJPR%DLY%s{7F&KANcO@WEGww+_Eo@yWNs_@(nAJo0oU#ckTH z&6Y#zlN?z@j@{Pyg=|{ZRbP0f-{3=fhG}Ee?xZ zKB>6w7#_R4H8zFv2+3MJ>m&X}vl(02d*j)yoei-e!KdK3Ipiy`1uu}A%^vo1KkJ&@~hCRz7apeoHO)RMhsT_G{0j>ECI8U+6CLK4n zqZS9(CmqCCP4B}pg=y8MMRL4Q$#08JflQTQEg{i#0`r3%Ex3k3v!Xyz_I8WMjyfEU ze?|Il&jtc|dE}C^NNs_tHVX5K0RQi}$uUmY9N(1kg6wV0bRmbtMo29s9ccwgpF^|U z!jvs}oY#m3Mv!kfo`sOoMA3l(0%Ona5QnLia`HYoPhPoC&RdU9xr9An(MEy0ccY`) zuLT*$uicLDXVK+XrBZ4PYcR!wd<`d-?sY$5)B4ahFikfOtBZkG@P6BLtJT~M{d5|n zix46WFM;N-I4`54N`AIMt;~LhJu3CI;2V0?F>~sipi@{Px#6Gpijrx@s5t}#gz`2} zPNn|kFeb1ySTf33HF7eHvY15)%%lvO#6>#h)^(Qa8&s9?%CRyUBY}$$0>=|D^b84Z zaX!LA@kCmSFCd@)&fbPfyte5QpbdWC!q z0skj`5F?IosTjHlWAk4K)W(p!pEyE%!rf&td4os8UP7VLHFJ!h*p)E?fdi^29&zi< zyJ<7_&m5r$x>hGUPNg`Q1CPx7-%rye z;EY9$Dt{u91s z2%$RJT9a3Bn*~#%C?dSk$AI?C)of`DQmP4SdlN6Vj_k=!y|pPbcJ|bzdc@7;xuiZ6 z3DvY?xKp5FWWQwSZ=(b8eHv^^fJrGwNQC+lh42GK_T|u$}lp~$rbdDF-Jwwya zTjh3te?>`vUM4)1P^v%xSmPFZqk=|1mbA^uf8i15m}Y8vL7&$VF>U{|u?&>vD^!!Y zBD%FB4Rx2Uj*9Pgjuvdo`eX3lrWZkCldSxMKhjYDW4ADr62M-0TqYF!r1%-Iz|bsQ4RQU^my>Mk={$PZmfUuw5BF{U= zUJgfI=9iaWpAZ4xD;b|%{5}eNFb8yX^W}ps1QCNC1P`=`Fg=);jZ!HjJB30wh$BSo zMVLgLti~sx*MSh#wACl&F_}*e$YYfI(L)mynYl+AqR$H@f+@{&|rcqA;0ZR!=_c>J&}{K?*orclanXd?wtc{`{dM9yDA7Be*157#GEX?`Tr2I zANctkmgBLTW$l=Uf zr{=8q3Oe>6Vc!RIt`p=)R=AV*d;B{xQUl4veC;{t)jSh8uBiI(hh3KdAYVDVwo;mT zK252?R{eZYoeUX*Z@+L#$-90Nq8Mv|zJ^X+*aGWN$){%nNIf~dVlhNqh3Kh}y%|dg z>b*|UYrt$NEKQ#)vwN!^=d%e*er$yg)!std%RtW8!y+bC(KZX?9zTlfxO1s1P~Ohxf6^BcI4Mq z&tL92H0qeQuRLUK&-g(!f_yMU7Lja?A1SUB4)T_Q$m`K?X5!7F0p4C;0f zVZ#3#G={fBa%&i=m|%f(tZ&}Ra2K-XGpNY?uvM`$#6NbGKj&WQHDnLA`W+5PPA`)3 zh=J)BM#Dr&ar3{pq}$?w`u(4{MgEap{GwAiJO8*27-zT%KI#j=DSpi85AZZ z_`o;OB5Bm0LH|$Ov7MQcuqnv944gy{e_-wlsE3FYA9e<=AH@u^YiW_kb0#4!_;R4~!}h3g3xVl9n}^i> z#oG!&$xjfctoc<{Zgq81ZOsqPJv@q$;97Ao=Lh-nh2o9M6d3sVlchffb!-Hdw1uKY zzMg0qp;Kb9H3J2TgrVh3k{IiF)dBEi{SZmTy2EzL+`H@|9j}PlS;r?r5keP<$X=zb z@_qX!XkwQ_=|Wx_*6CMFl)xq2<2yzKLh2o%P@%HV3Mc(QY>Q=Oe|#dT)%Vsb?q0*T zt z27pe=goFDJ!-q$-ZFbQjyv=TG<`4ZJhA%YSLniza#ymwQhD&Po+`!^tK9$bm^7Q%o zu}=};HNou5&*`c3S*p?2L;U6m7;ny(nKV|z$JY%=ogH#K3LCQrWLb$FGM7>4*j6luR!?%rl&z)cA zbs4{u{gwo1gZ(qwMon67&sWeU297xZiESk>NJt;8zl3nKkg)F` zh(n2xaOf!=L`9IjQ#giZuEL+;;!=xLp2Se*pWEJlg!Ttensrid4UbzBHnhEaVWq=C zP?vcT|G*(G1ZNSfs!FZPzpJNhEiZgp_re~tc(Bi0*&?;-(xBQtKg)Clz* zt;qR0edJ4%9)H!0;c2BG3WG5;Uwn?P5@^@$x zNxDk^`M!O6zexXaXuVnc_e0r*n}hdk?#!2xk9ohw%k`=L->EFG;|Z=SuDeUZulHLV zL1b&hX#&2C6Cg_N?pkN<{j;EMW{k%WTZb~6>?L+{3b1^o{bvV>Y_FbBl-Xr*WBtt0 zruJYA@`Up|X7-IWmD=uNGLhfJ{ezngUkTj#ea%H~RXREL2D5_Oys9QyKUyDCC7Kpi z$o`y`>DATQ#hJpXG0`U@6XRb#3r&zmAW^(!52_IQV_4pAV0xZcCNdOEA7}UxEU{+^Dw3rUY<9uI z`S%QV=E)3<${>J96uoXgs<%1abI@>C#yOz}P0-UU&JZeI%+F}QxkD$=(!fQO!FDzO z$S?J(TSsfZLV6>CAS(RH+hg^QK z%73szbu9!|593dFS$Q7PbuS4M8~4*K?-C=8XiQOJzTk=34^fxf4(Lpjo+QiflsAb z%=Yk}(Yv1h^W?$o>9ISn`Q=esXEfP@FD>N?{wW(aHgX3Vbo-pX3G`-xfZfZ|T{IF3 z^p4{zheXD)Pj34Vrie^Z;)tq>hm9yB*?fkB;rjU(yZ}P1P)qch8Uv1SM=>M zi{w`rl@q3Nv2~2$2hYZQ%hOmp4X&D3%GF$g*6U_G^H}+Tbqe;btn{upM?{pcK-W)W zWt)HxCaM5atJF1V1CYmuSAclo=)Jp@`$R6ptf>?BtOlnhp4L#_fdc0{5aIy6&*yw) ze`VhUSrj+bk@oo+YQfSMDgsM#saIhpa7yjk07DVNFO5e(7g?8c?G?XU+8yr#8@793 zSJM%WP@um_lX0yfUfV_9{NRMptG|%GxqX}|MNK* zAULaTJa)(dKyCrMDo>>3sJeLoH_3|Tp*no{TCPW1o|=>Iiw1m09QIiBV>2pOC#p+d zKg^)Wi`)IBhqm8J6UUrWI_5~M29e6wFT0_-ncb+ZZ3v*{+Bi&Y(F*hEW8Oe#48ywc ztL_Oxx(&-Ipyvs7PRJi++CXdh%XwCiyc;>vE!NN6X@giY)M1*_I=q9M+gh>%0GUd{ zG9$X(Q|%T;8v!9PA!oMZ^s_Dnm6+iyOtUa4u_3@8iouKQyTn}FtQ3SLhs02CprN5I z?LYy$P7)8c60W?nbGKC>pinO*eU!8bV(-)y8aaSec@o-A$*%nkK0h4kUziQvE^kv{ zcWFV9NJsaS*eT{7b;#?I4+_Bsjen6Pkh`l7Wv%G80ss6pqVE?GQ)WDa2nl5vz;d5d=;_QR9hj_q)wAu(q8Ft zv+V;&(zxX7REG+;cq$^|$*eM>EVu@hEyI~SI~y6=QN?iu9RbQQGr2rf+LelNV3?=E z^@lwMFwaU^GKw1)(UvxVr;upcKBwJ12X_=>Lrgrxx6lx(f|)9As#kV@WGYO2Q?vBL zchT=6Oz|1~IRs~VU0#VyyxQIBy|+i@w!GlY^QQ^idUEv^OuzB6-!m|hMBA5?P1zDF z#dUE2OEHw(8FBYVbNEY;HT#p%Mcxo@5P6O`@T^GKq~KC>q`oVD>TV%`A6rmAyhG-x zDau>|I!s8yDdo~ycJ@-{{%dDAzEyt&2MtToP7Q}f519>evw~B8n$PVu@QYH2^N+*Z+qY<977=cXVMbu> z&Rv2W#NT;$v8^K9L8B{!(EUxe`Gj7m{Pz*Wka#1((p4wRhzpEW$c*6cKaLS?NG=Uq zihxBDZ)!?wggNiY=eVte&v}SqJ@2+8*9=$mh3e>8P_A8z=ez#+A2+l;5^ClBXD_?| zwX=9h13WyyN$Gw;l+UH|vZl|*vqh2b`NoK9$;x6Vy-}f&K|4+z>BTl2Qmbvf?Sc zG@)RkqB{AAN_i7=2~+%Ooy`W&danJY~Yxkx~5p#TKpe%^L@r}O5rB-ue&T6ub<(B+^b zCx!Dd166cf=EX5S+1V3}CyMe5vFszYC5es8q@)?8S~F&Z`7jt~0&nY6m+J+wJUp`F zjYhsofr4*qyS+)7Jf~$mZ32m!J zj7!4D%8Fl;Q>?rGlfIiL+yq=TZu!WHW{y!hG+qe@t)0FwUpHjAORPr!JlyTL3~or# z$_xTKoJNTjV4aTmp?E7#`%bwiT+I8}VS4M}xYDFF^jN6OHD!F~=2Vx14D9x$S=X&R3Qn%i^UsUg%9C&9e$(t{d9{+IHy2 zn4sQ1=Oe;INItCX3*O(&vOC81X7X1TUr4T672DY#2OGIy-{-p-B{gg`vRFM?ss4Qz zJZYt8jP_tjcbs%I57Yl#d-ptWU-E;C(S0(@*NK^v%zew9ZPgjXd{^xCdLL1=pRqGu zSuPxR@r!Q^?VMb%qf{`$LOehQ_Cv~gV^_~!BKHlS^c=?4I)wje;>X?HL3z?Uk1}?e zZ|cU`9YNU#8m~3Q#_S;0ooR3X*vYrarbJn8GKV)EYYIObx%$<3q;L3%Egx5FoK0uj zvC*Ty``&fC&3MTsV?W;Rc?-3avwzkTTAh2sBjs&5W~n+XX#m%vD6VOUPF5eAcMts^ zHCSe2EdTcJok&*FZ&LguL#hAt@jXetnh9v8*eky-h~lS%CM&Egndcke69{V(T22Wb z5~AosUA`uL-?=55MRxAiXe06SAKew0VWEJ8iN$*^EjNiOhk0Zy@Vc7JT=jZ&`2sV< z!UgZCuIWLl=@TudT2*Q#EO%{P^$4|u1IeOvGREdVi= z1fRM=D3-u!nUa$vc2dXq4p#YWJnYCOx)Cj_Jtmd9(Z%Gdy*R9~Wc}%LkV({(FK+5;|B<4Lf*+yp$HSS#3y)#MW&nE6a$3 z!|InM@6ZC#(2%*h+6{Oi`h?GsCXT2f|D@cfg<`YZBTX9RbD83Mn(+O)Iiv|ds$}%# z=iRXLL-f|gi@$`?2?RjK2o_3_W416a8YMoC?t(CGvNjImkMjVDoFh>{Qsic6-NOag zdQMr-A7k|r4lXWww!AL7^W4kG@I`p>$X>0N(sOkSqT**Oc_hqjcg~&_FO944Z>;eA zlR-?XJXS`KU5SwZ?Xrl1mFw>O(pqIPWgX^>ij&}7E%BqGiK>LId_3Rj(hJp&so1@| zKE|GD30`I0;o0>qez5y(hORuZcaQQ?fI1Uy~5$j9fnrPE}*ZM_0rGAZWK-Jk{g zUBCue4U~SYEb|tWr~Vc8*@aA~QU0r64K&WQ8tbz>0Hs%6^C|bl6c?K4`>}Cq%Ze;B zwUgGFln@U=wdG{uN2|cf0U-Dgm^l?}DKFh+?{AdTLwvG-KoC;)+ZgOhbj{7HzvynM z53=&4OdAi>YQZLGFOPsP-oVZ>tX}uKdj>2IX}*I7(HGinVwmg+_NM^Iu{aVIXNi7r zDgyrl^h0V&M}7ZBTkwCQwULklVoB|^JJyoaXnZHGM;rJ?D*x}f&|W}mKNOj10EYj@&_L?dBG=1Y3tb60#K(9gbUO%Av4$;f*!Y{%(zsF^ z9E6tC9llqUw43J&l$8VWS*J%^jYtVsx34HPHCA$FgPdimM+~CPD@}srLW41_%Y9Ez zOLe`Y$c03V?h>5H`PDUTfFAlyGXpW?XpN?V+AjE*mrc`OOS#!!t4z`3>sfC4GmV}&5e0UwBwgapAF4oZM;KP{?=J}W!pl3%~&Yx=(xiUBh-LwLJBgyM*q(p&sy*K zW0M4=oPbIW^XeF%mvakMPBPNCl&bNI1+;K-A_yUsfk^70WVEF+A+ZGVI^4G*r}LHF zHTeXT&g-H698V+U_5GZLC1tz0A@ro?FV+iPh=NS8$yDEe!s|HZIri-azCYa6fTdn@ z^?M)_RBBwM+u7T}ZL_DzRhUd=s?VHv5X5c#0Wv&*>nQ5ND_kqin5Tu2RnSycFS!$ppIIa^XeNrJHN|bxuZmJ_p(Yz?eowzfIWTvE(RkE8W6iV z>st-A9{mAv_X!IK-a<6Cim>^|U9AIM$6^nfDaO_lpWcL1YoOP=u^itH)Mj_u)vROyQRDU;i)36-*hRQ|+O<~z-5bKUjL|*V^GF14 zYBL`9Ie>J2xi~Wr(NP!746oye8*B@ ziI)%>9;Rf*^Rt-7IkMuW-cwn%xP7}-WT~gR9O!EiwG-$~ft*~=`I!in?3U~Z1WDe~ zTnW)pEogyE98k{d2;2B>2K8j_7rSO0bBw%!a2y@XEAq;n7>7Zz@a3eZeXyxWc!NyY zuB{`C=~+&}&VF_V+;cvbp_lOez0i6w4Ey>zBbn5MKTfm6#|jzqNXZ?9;j%2nZWBKQ z%Rs^RU9uH;AWCrAA36pC{dx01o?pg1oLOtV_w%BAaB7n@*SUaVEYDxKe(!wURG%UJ zJq0PVERTN+HVO>kS#-9G?XP+_`gO&h>^*c+?$K4@II`uw0-TvL$$`|FTm7ecwB|?D zr^l3L^BtTXQLw;)l?MYwE6m*8q;2PhxLiZoUgSksADM)v>YhKAOdK6NQ8Ef;)7V+U z2)R_KXg=ST(K;d@2G~z4X!|j#y|c`nv);MRJd%8036rb}KmV63;#m1zssAO5ga0K9 z5;+mzy7IgthF+MC46Fv~04|Q=ys*xI2$geU}}hVnZB98 zVcXroF=sYJxy$PF2wF8|ifIgbk^K!+{;PC#+ni%@Z0jM}_4^@g?fZIN%alB2mt7O`-gq&;HL|Dz8R+RcC7Cz)8Khw7m-o=Blf#Qx^t3Vx6zeo2 zQ9g01-}1S!7xrYqIIz#&1Eq#t5QmK|`RJNkvt#Lex>ZVru-Y*#TL)+KbF#-W)um@f zx0HStU%9_>_pp2ijNdRT-+tNoSzs|gJPVRairZ}pc0Db8L>tNaDSN; zfDz1@@=+x0Ix9oNW)vk>>OY0Od?uzJh&OKk{T7J}QPvdyhGOvmnTlFCRil2Q~IfEsYVcxu>R zJ*FHyoAyMj&4ERYu|#=o?(^%cfx=1pL@-2|h4gewqnG368Jp>5=Ik&j-aqvNA|>e5 zlsc&+*I+kUMSojcVIqs(h>8uq@hjfs`#YF(Y?9jiRk#~>=y~aW>fZNTe%msc7I zE)h2AI#+3lql!jeQzJPTEJcQ?8X6ram$@XqY**MNZW0S*%$7vy#ZwNlfjiXJUF76Y zrITMkwf|KZbrO^JkT+x)9jCfM8_49@@4Xg&i*FsoQaKajDYBtx420Waw5BY>;J|4< zEzlO$FeYdPNI%h#NQ(&1?jbFI|9h-79*hP`3?Ybf34Z2*hdrsq%8cMLJ8=rmG!L`Z z)-qLvInC9(eE9?1Cg zVD46{(@u*UF8-!((DNHL;QelyuhjHf@yP9lX}tN>n~;7Ocs5un%oZ%uFgosBf5#j^ zx|}cnx57e`d;BS7eK3L-HbG!-!}@rizY^64w>H(b`M#Fy)}>lEOxk(bBp8dpwyM2zc5$f;#-8vq4ZD-}6)WQhk z{8RfYoClFCr!e>##pPt7PI>VT{n(e%?h7|_|E*P#f536H?IDMii*vLkPRvW%b2a!w z6^RGKx`JtS?l9T-moE9TlqBNDoVEb=L{8>4E>lV$pycp3hhJ_~+;#Um?#f&~9xcWhu5g2- zoeA{0ZFqE6Y^dmiU*-|Ws&hI7VmlJSDp;lVdY=0{14I3L z76mGXd!lx0zc2fFvcY|@g4e&dg>;OP`^HlbN-C;s zfF&CiiOOCY4h}80Zw5K|U|JDgOLv7xDoZ1 zFlw$Yi6Is4dLxHRn)TBJf!X`|=Y#1kZRUvLg8}pKmiQTeXjtd3p?+0u(?fSU={r)g zN2!v24`*)n!`(%w!i#hYI)mEG3_etU!NMy&0AWPtsMo23#R z6#RIygqn?IV4s?t);!&+Y>BdK!#tTpqm#Gv$nw31^6m#E2f`Zp!KSi6v507jD63oz zAwAC25~ozrr%A~G>;rFh=Uv0!Kd58;x}@*KI$C)N2V8qKmO(R<@x`QVz>sZ4aCGK| zXC4bBrhE~!&!6TKp`+1=!fSo*OgEAJ>%7uEp@JT}1(*t%_RC#xz=!#_VQ*@sgr>)8r*DJv>t{%-O_ zRGh_{*nBuO%A5EUcG;tKSjX*Wqzum{^fsZhKM}j`x6?k+g+!3(1KDC6MI}dm_hip* zNm@X2*pBWL$yFof%UvQuXG&Y2pJ_nre)ITkigs4_XoV>8KDs7VNKh9_Tig;TL_pP$ zM>uY$DSt)sO~Wt+$xPS0pV~VTfwEJw-(l%@_5qK0ZS~`~#@8yU4OP*9zphJLU9n1* z+cy+dB)=I$_q+(X045xtE=Lio&OKQNnR(*tU%{Hej7pF}GSm)km`8C%@DS^SNZRk z@UQT190g_y&Do zCD)BexxEeSuE2K#c_<_)OJud%xd@qpU!1}1GXEa{m_TR0CE*JazqhHR*#B(U<^N0A9SoNQ zTwzbZ9hPdtr6qOYQcr!@|6HKt1pb+;s$%*rLh-)=P)i30;icn224)EW04frbp(GrV zfftkAS}1=(6g@*L-F~20QBYK5RVWGD4H!v-!~~_lLk*^-BtA9M-P`Tb{mSfa4KeaV z{1?UqjVAs8f0XgIXpG{6FEew_+;i`_cjnvo&tCzoV@crM>1ng}M(;{%K!L4q>Q+x* z)veHvTu&x$7#MzN6Z48Zk}>gRU&e;jCu+o~tXb=i zIabwv>3gZ?F%kErvBr=B#|?;-8#v4kNyS`?`C9c+wPx5f)Zc0l0)W@rE4MO~oW_^oIqBWF(pv@OeX12=gpkg2R33C#W-^elBfn^X=Z zfyu3LYzdc9EMN*(1oA0ctM=KOhO2+LYMsOh`8iw@C_0q9R3Z11oCqvcE;?DcNR@CM zHwu`+EEgUPBd`UG|I+^S%qec-*2w5QcWPf&&qu4_4x=PI4;7fH{ImE1?v0d-C1}X! zaS8VYvd{Ukvx^LJ{J{ig=ezMqLjgtJA2M3T1fPKUFPM7u5!2=JC(NDUcKI$ZXV5?3 z!FymV%kVmZ%nwjY2MDcD`jnI5Tw8w&d>m!9KWFwavy<&Bo0Kl4Wl3ARX|f3|khWV= znpfMjo3u0yW&5B^b|=Zw-JP&I+cv0p1uCG|3tkm1a=nURe4rq`*qB%GQMYwPaSW zuNfK$rL>_?LeS`IYFZsza~WVW>x%gOxnvRx*+DI|8n1eKAd%MfOd>si)x&xwi?gu4 zuHlk~b)mR^xaN%tF_YS3`PXTOwZ^2D9%$Urcby(HWpXn)Q`l!(7~B_`-0v|36B}x;VwyL(+LqL^S(#KO z-+*rJ%orw!fW>yhrco2DwP|GaST2(=ha0EEZ19qo=BQLbbD5T&9aev)`AlY7yEtL0B z7+0ZSiPda4nN~5m_3M9g@G++9U}U;kH`MO+Qay!Ks-p(j%H||tGzyxHJ2i6Z)>qJ43K#WK*pcaS zCRz9rhJS8)X|qkVTTAI) z+G?-CUhe%3*J+vM3T=l2Gz?`71c#Z>vkG;AuZ%vF)I?Bave3%9GUt}zq?{3V&`zQG zE16cF8xc#K9>L^p+u?0-go3_WdI?oXJm@Ps6m_F zK9%;;epp{iCXIh1z3D?~<4AhPkZ^c-4Z}mOp@Sa4T#L5>h5BGOn|LS(TA@KB1^r67<@Qp!Vz z2yF573JoDBug@iPQ=tr2+7*HcE3(5`Q%{A2p%psJG}nJ3lQR>^#z-QI>~|DG_2_26 z1`HHDVmM&*2h2e|uG(OXl?228C|G32{9e%Onc=sVwIV zZ=g2{K5s0>v2}V&CZi1_2LA=x)v|&YrWGaHEe3L=lw}aSiEdWu&2-C5U0O~MpQ2Hj z-U8)KQrLg0Wd|XyOt&Gc+g8oC4%@84Q6i;~UD^(7ILW`xAcSq1{tW_H3V};4 z3Qpy=%}6HgWDX*C(mPbTgZ`b#A1n`J`|P_^x}DxFYEfhc*9DOGsB|m6m#OKsf?;{9 z-fv{=aPG1$)qI2 zn`vZ(R8tkySy+d9K1lag&7%F>R(e|_M^wtOmO}n{57Qw_vv`gm^%s{UN#wnolnujDm_G>W|Bf7 zg-(AmgR@NtZ2eh!Qb2zWnb$~{NW1qOOTcT2Y7?BIUmW`dIxST86w{i29$%&} zBAXT16@Jl@frJ+a&w-axF1}39sPrZJ3aEbtugKOG^x537N}*?=(nLD0AKlRpFN5+r zz4Uc@PUz|z!k0T|Q|Gq?$bX?pHPS7GG|tpo&U5}*Zofm%3vR!Q0%370n6-F)0oiLg z>VhceaHsY}R>WW2OFytn+z*ke3mBmT0^!HS{?Ov5rHI*)$%ugasY*W+rL!Vtq)mS` zqS@{Gu$O)=8mc?!f0)jjE=p@Ik&KJ_`%4rb1i-IUdQr3{Zqa|IQA0yz#h--?B>gS@ zPLTLt6F=3 z=v*e6s_6w`a%Y2=WmZ&nvqvZtioX0@ykkZ-m~1cDi>knLm|k~oI5N*eLWoQ&$b|xX zCok~ue6B1u&ZPh{SE*bray2(AeBLZMQN#*kfT&{(5Tr1M2FFltdRtjY)3bk;{gPbH zOBtiZ9gNYUs+?A3#)#p@AuY)y3dz(8Dk?cLCoks}DlcP97juU)dKR8D(GN~9{-WS| zImophC>G;}QVazzTZ6^z91{5<+mRYFhrQeg|Kn=LOySHXZqU8F1`dXWOJ?NViPE%& zFB1@$8!ntuI?)geXh|#JJC1+G^n$h4F)g-P4WJMPQn{p=fQtw0)}uk;u*&O2z+G5? ziW_=1kTy(!AJzj}de{a9WHY+*SqJ7` z={VTi)3NK|)*W3PUT#5a$D6oyqH%5zjdO$5ICHx_V;1Z)4A(rT6aasvZ{{r`HnxK7 z^fMLS1{;H{o<8j5hz*F@WkKQmDI*Q%Kf$Mo!EpQ)=HV^lsj9KSz->ROVI zrXAI0!Q?WUosf8t6CR*rl382^sU3q@($L~EC(AoyIjS&2(el|I$a*8oAtqGQs+O~huhBCOFw(^b&bol)F zWsp15Sra3v%&#wXz*!kSi!sV>mhe(I z=_Zxmz&E1>i6=yB*_X4M#ktdNg7_G}MVRGQ7^zX=+mQ}1xtg7JN9E(QI&?4}=tP2#z2<7N%zf9rxzynL~ z!MgNpRvXaU69c*^X2(c?$=h&o~Fvv06*{JdsM!gF$KALcW(}@Q&Alo`@ z3h!H3j^@5rFMp8l6-q!cb?1iS$oZfU+}A2<)&2ZoL34kkSnbf=4>qd%guV7zM1p=amds@nhpkK7 zmRJlb?9zYI&?4ftd8+RvAYdk~CGE?#q!Bv=bv1U(iVppMjz8~#Q+|Qzg4qLZ`D&Rl zZDh_GOr@SyE+h)n%I=lThPD;HsPfbNCEF{kD;(61l99D=ufxyqS5%Vut1xOqGImJe zufdwBLvf7pUVhHb`8`+K+G9>llAJ&Yz^XE0;ErC#SR#-@%O3X5^A_ zt2Kyaba-4~$hvC_#EaAd{YEAr)E*E92q=tkV;;C}>B}0)oT=NEeZjg^LHx}pic<&Fy$hApNZFROZbBJ@g_Jp>@Gn*V zg{XhVs!-LSmQL#^6Bh-iT+7Dn)vRT+0ti(1YyOQu{Vmgyvx3Tuxk5HG!x2a+(#>q7 z#Xji%f&ZxT@A*$m8~z`DDl?{&1=gKHThhqtSBmSpx#kQc$Dh6W76k!dHlhS6V2(R4jj! z#3(W?oQfEJB+-dxZOV?gj++sK_7-?qEM1^V=Sxex)M5X+P{^{c^h3!k*jCU>7pYQ} zgsEf>>V^n1+ji40tL#-AxLjHx42bchIx9Z51CG4Iboc%m0DAfvd3@b}vv4%oR zoYZpZ*dW?+yTcduQlxreAz&6V(Tac9Xw3_`NotT9g&r{F_{!Xb%hDPJqn`CWqDwai z4M@7F4CQ?@C{H~rqxXwD(MFpB4!uljQmH~(TXJJj3MEVHkt7r8!^R;bp!H=&%-OG& zONKIOgLJtng(VD0u9%2LuXKe7h$?9lQ^#cLOo}gOx^+ixt2Izmb6{J`u0VexU0j}8 zIs+?LWLGvQ66Pg0ax4n^G+xW-rwp&fIZ0}lI?y~wn^6o3{jj*VSEQ}tBVn1#sVTQB z(l&Gf(sriC0DKR8#{);Sgb5%k`%l#BfM#W|fN5C8APnl5w%nrNi{BWrDgudYAZLGE zQKTzz^rV(Bst!UI7|8?nB_w}@?_pYX_G?9igK?yo0}({MC^6DiO!bB88kijN>+BCQ8v!rg{Yz$`Hf$tB*WdxSPHMMkJ{&p0(lyXx|^X_VUQBdh9)?_2P1TViiYqy+ z91$zg%3%OjzWyY=X^f7I)2-34bDVCEhECAi^YqS9x@(kD(Bto;VDKfgIo-)s_q)d2mr4O;DTUTgjOe4f51kd6T9 z`xa6_AUP*N{jz%!Z0E!Dqq}JlfPZ2EyGN*EoPHJ^rT;z^0vaI03Z(WcdHTh1suHxs z?;>yWLj~GlkAQ#jSWq|nUE}m()bBZ1`Rh^oO`d+Ar$33kry+En{&JjrML}&gUj3pU zFE58(t|p~g@k3p&-uvoFzpGktUMnQ6RxDA&ibYl_A!{@9au^_fB@6;1XHLORS}C(H zi&J8=@>Kw66&QJD@w>_I1XJuBW3_vn?f~bbTv3_J^W1+E?921QNo!MQiLHISD9?+d zP0BsAK+yB?l009uXXMOteoGX;?5I|RG_v#Bf~l?TPy3zGkT`N>WlZRa=k7Vdbz-66 zIQ979fX!i7Wen@lu-oEcweu$76ZXrc&JWRf!tLRg2JqNG{;`-H@L`KHfgY-Lve@vsPT7B0@716|Z$Z-Z{!W zV;qGHV!`h!S>b)rZpc`9J))^79ey;7@-=zZjys+j=U6maKhDddqZ}XQffIbFYn)R6 z57nRGEG#j`M-Gni4deWVXcr=HoNok4SKTPTIW&LDw*WrceS&Wj^l1|q_VHWu{Pt** ze2;MKxqf%Gt#e^JAKy{jQz4T)LUa6XN40EOCKLskF@9&B?+PnEe(xB+KN|M<@$&ZP{jM;DemSl!tAG2{Iisg ze|}6`>*BENm!G2E!s_XsaUit2`a&pfn!ggt)wG<~NoFFD~p(1PRvhIRZaPhi})MXmEm6+(X? zAw+GxB}7gAxHKo)H7d=m&r6ljuG2KX{&D9ANUe9Q=^7yych#S!-Q!YKbbka8)p==A zm-8`N5_Qz~j7dxLQeaeCHYTma$)Fy}ORKS45sf%}(j`4U=~Aq(!-|ZRRXvQijeGJ^ z%cq3itmW;FI)JsU8k4pNmCazDyH9@=bqwS9q)y8?KhH}MpVTd^>?u+Cs!&l|6KH<* zpikOqr$wK%YZ7(>z%vWLb^+m&cCQ+h_MDo+aXmPW7CD|K$-d&cg$&GVPEi#)hPjGY zx|SBxatca)&Ig?*6~uiQKE)tF7l+ci4JvbZ>vQo}1mB z?m;{w?j6>1xBD9F+2p#YP3U>vfnMicQVHdhK1yDCfacJHG?$*G zdGs93XO$LkB~?nFAfNOoRY`xRs9JiG7CM&Dd5!=ra;zY~qn6HhG|^&58(rYoNlP4q zwA7KN3mvymz;PR0%5d!IoDF1vxVxN zS5wG&fEt`JYIGi>i=Fq;YUc>8aXv_wIKNAmI$xs8oUc$5M((w)<+NMQ6{7X7iz)2t zqz$eebh#@<&91|=(KSq0xZX>fTn|!v{~LlTjaOXR{3kx zDZfD5rHpl>gbmAU@|wOa$t%grx`7}nA|ePPsN0Y)k&2=M zc4?uE@gW0-f>S_2bO;VnKt&W3k$KKdvZh@&*WWKa@7#~`b#Kuyw9kqdj%CMuQ9ESPc-)MbM#7}YUL)ZP_L{+s ziDWcU?e8%n3A4VsFYJpNeLjn2bT>CI3NCJi7EH$DX3S}9p>0NY z#8jZt#!W_KUc?R>k@Ky-w6=+Da+_s0GJldlF|P?(31@{B7bweeajQGYky;y%9NZK$ zoyN7RTWNn&2`?k9Jytjwmk||M(3Z!M&NOYwT}t~sPOp`iw~(CAw<+U2uUl%xEN7WO zyk@N3`M9ikM-q9|HZC|6CJ8jAUAst!H<<<&6(6Zvbpj!BrzUo!>VHN3A3 zvo$EF5-6b1Q~ajXENB~lhUA@|>x6=N0u#cfv&w(qgG`^+5=HoNur`2lvR~b&PjumO|P8X;=d`c+z1YJlY7&H@Dz-Rts$X0IYE9kSIlqGZ7utSx^+2hOEC-eXviWZXQ9;$Va+WlHlU%y|f~ zw(|)o@(5J0o|3MQ2O@+B<@r*H4*65)(r^JTq+<*b06XMGclsEElst5dEfFJ;AQfYh zRt}O0CVKdGh4Tk3-(^-{kukZb*3oM$ZffpGMs;jtk2ZjAsn%mND4R~OS73JDbj^Q4 z40{oS&4<@VUYMInc0xxy?FE@$J_^n)b|gY+Oj;8Pk^)6$w9nbnMms3RSr6q(9wP_) zv01|=P}UbkXoS_1#FCl?>&9cjCHOS!yEJqiGd`83Nj00{X6dHFN84%)I z^*MZ=*Ihw5FxD0YSJHV{j!9v(DT#k7##q~$87Dig!k3EiMO;k|9XhYz8cGVPukGe$ zN5@yNtQgngIs(U-9QZ2c^1uxg$A}#co1|!ZzB|+=CrR6lxT%N&|8??u1*Z?CRaGbp z6;&#}$uQEzu(M6Tdss;dZl=hPN*%ZG@^9f*ig-F9Wi2cjmjWEC+i?dU`nP`xymRwO z$9K3IY`|SvRL^9Jg6|TlJNEL9me$rRD1MJ|>27?VB1%1i)w5-V-5-nCMyMszfCx0@ zxjILKpFhA4*}fl9HYZ~jTYYU@{12DS2OXo0_u+ot_~UfZNaN>@w4Es$Ye>i&qhgqt zxJf9xi6El-@UNPeQ>aXcYVxOUA--x3v13e=7+%#m@}QuMTjN3n--=-{@rNtyYd zYS@LJ(G?*np*HILbUeo)+l8N#+F-;^(8w>i8Q6til8Y^NG7_qa*-n2|4}(k<-HF~R z0v*cP7bxlTWNJ1s6#Rz!NCYesAbm(}4qp%-;B%AF-LyS5Q6@Q|V z&Y2ar$uWn(?UstqXy;5$ZOCC_?L$F@o#dk--?Co{)CGEP^73Kb_^>`G8sAN)M@iNKQLBj>QAcHjIw0!1l6{UYd;|bA+CcC#3IGYysWLa4!KA}C zsEV#c)JpJcF~NX9mrX2WwItXv+s%I2>x#v)y%5xDSB`&bU!9COR@6LwbI|OQ&5mf& zL^GGZnOXEOLshxOs;Y;ikp^M(l-^>J(o0NIdbt5`(fTq>p%?cG;%aHXhv=-@!20#xf*q)++kt8IJ5cG{ zff?Sy9hfzQIroA8N>Git>3xOUNhe8nUspSV`GL0DK}<_w!3gRCwOvD~m+Zn6jxTMd ze<_?egr$S1OySh6XsS!0Wh)wJPX+xd11YQ=Mq7X2tU;U;Xx|ObfO}%y{pchi>ryaM z2zAy50_$ltt(ew6h#CF@+U74D#H@hdQ=dX_=OChf#oerWnu~l=x>~Mog;wwL7Nl^I zw=e}~8;XZ%co+bp)3O{Mryc`*3ryyIC*S%Zu;8Y_D3bFAn%8 zNTYv?y_%Q4zR-DvE(Q*~>ec+JSA76q7D#_wFR&HI@z>V`9-)x zr*ME%7~<$Ykd?U8uZ~EqUe&AlGDqP{uUvnavy#q%0y2VKf%UxO(ZC2ECkuzLyY#6c zJTru6Q`qZQQ+VF1`jr8+bHIwcJg}=iko8FEDt(bW8pbOr>?{5KLASE=YFFv&(&IM| zP6@wK(5#jhxh@Pe7u_QKd{x@L_-H zM=1`rX8`BDds3pf+|$)DBqpXr zDP>JcOxubC$Dy60;8(mfG^6yXE(+N*UWMW?A~?H-#B7S@URtmlHC|7dnB!Lqc0vjG zi`-tNgQ8uO67%USUuhq}WcpRIpksgNqrx{V>QkbTfi6_2l0TUk5SXdbPt}D^kwXm^fm04^i66Xn0`pLmnhX(P0|TezLiFcQ{E0~v*cmmAR2|PET zl7Ls>OakCexUmie^yDw3ccuqd5(wV_6?YM+egsV{M=^n{F2a}~qL}DfhDok9nC!X$ zC9WV!U15~DF2xl0YLvS#K!rPqsqS7(b8m##ZA(3F3H0v&0Z>Z^2u=x*A;aYh0093L zlc6LWl7U5kwXW8By76umJat{FC`H8^K@=20LGUu&PPftQfn-}R#6E~`;e`lZ_y9hX zI9nAF8OY51`Q}eZ-alU70BmAj;IZGoXxzI^8QfCba(CUJ?bh5NiBhFyrjpo;k`}RU zNRzb0n;mJrphLl}?MBw!ZA)#b=BA++$<$N1M{{R?rygu>Giw?@^X;zIEZC0p>fBNs zs+h>AIApa)#`0OLH#W958eWTf?n4PepnREhO+ZIVlfZIfLO(RJrOCfDGEK?&C$Y_> z)=S^{Fuzz4!va$`vL}5lXkrYW%bH|gUK?As5mHLYz!l)Iw)g2uVw^> z5BZf)=cdR%GlXhRaaGM3&Vs|i1g~@4Eug>wRMxJqUof@)jOp4lW}kooS{PUqJ^@fm z2M9!-I|6Hyt%6X033waFb$&wt1h|3@lA>hju-BAmfjCGV5h+8q93HYw5uy}QM_|d8 zm%xHt3D{+J7m{e#O4`V2j<#tMr-_uta^2Q+TPKZL38bS$>J__n)1+zBq-Wa3ZrY|- zn%;+_{BHn|APLH8qfZ}ZXXee!oA>_rzc+m4JDRw#Hi1R(`_BX|7?J@w}DMF>dQQU2}9yj%!XlJ+7xuIfcB_n#gK7M~}5mjK%ZX zMBLy#M!UMUrMK^dti7wUK3mA;FyM@9@onhp=9ppXx^0+a7(K1q4$i{(u8tiYyW$!B zbn6oV5`vU}5vyRQ_4|#SE@+))k9CgOS|+D=p0Txw3El1-FdbLR<^1FowCbdGTInq0Mc>(;G;#%f-$?9kmw=}g1wDm#OQM0@K7K=BR+dhUV z`*uu!cl&ah;|OXFw^!{Y2X_bQcDjSDpb83BAM2-9I7B~dIIbfN_E3;EQ=3AY=q^Dm zQncV2xz0W-mjm8_VaHElK@EC-!ktWFouH=5iBgisaA1U@3bj)VqB)H4VK|{N+2-(JHfiJCYX>+!y8B2Fm z({k0cWxASSs+u_ov64=P?sTYo&rYDDXH?fxvxb>b^|M;q%}uJ?X5}V30@O1vluQ19 z_ER5Rk+tl+2Akd;UJQt1HEy_ADoA_jeuet!0YO{7M+Et4K+vY}8zNGM)1X58C@IM6 z7?0@^Gy_2zq62KcgNW)S%~!UX1LIg~{{L&cVH^pxv&RS87h5Dqhv+b?!UT{rMg#O# z#tHOouVIW{%W|QnHnAUyjkuZ(R@l6M%}>V^I?kADpKlXW%QH2&OfWTY{0N_PLeRc9 zMi3vb*?iSmEU7hC;l7%nHAo*ucCtc$edXLFXlD(Sys;Aj`;iBG;@fw21qcpYFGU6DtNH*Xmdk{4fK0AKi6FGJC#f0@j_)KD&L`tcGuKP_k_u+uZ@Sh<3$bA}GmGrYql`YBOYe}rLwZKP!xrdrur z0ib3zAR%*So7rZjP$|`v$!nA9xOQ4sM|Is)T`iB$29KOE-0_Y!v(GZKhMia4am~e# zu5PJbJTk5!5Jn35E$W1AVWB&zA{r<8tP)wo%Vg0}o(EZ}Ts5eMgW$E9nUDxFyhPP( zs8$YB7)%~lUan?sD~~9DckP11Ea%9&uY)hvUwxUwb}pf|IT$VPqb9AAiAuw>G+8N8 z6Ovlm%$~Fhhg1!#<%uJPW4P+L>rOa{&N2gbFd3Fh-nnA8lL@IrHd6K33HFYag|7^p zP;EZ&_CU5|tx*P)T5w<-hNeoB7VAth{E$^zh&!tb9x@TA^<6WYl=|`BSI?aM#~0G0T^KK!+74^cJ#Nj`srvw<<6E zzM$Kx-86sp4;1hc2-blI9c0tmCMY}Qn=5b(4Vqv z{|sKKb)cXA9B?~>#9fzsZ29S1Tr62*LHahw(?8R{AQudS8<=zg^lz2q zD}8im+_uhWqYUr=fMT#sIo${8zZfe2N&j7)tPfNL^8Z2}6)v8;x|<$fDzHr5?L0g@ zAOmYTwm%3~HQmw+c~!W5LEVM>2|z;BF)jd7U&jQ0%D8~=0et;cR2&d~)H=6#Rr*B( zV9$6xY#V}Z4=>PWem5wViJ&4Bv3xeU=0-BSSJ zgLq4Ssb;S7t=xC1%@8T#c5w$=0*}ik;4@vwq3Am7=yuN-b_|MEpaRpI;Cvp9%i(}% zs}RtlP5ojEwsLfL7&QhevV-Nsj0eq<1@D5yAlgMl5n&O9X|Vqp%RY4oNyRFF7sWtO z#6?E~bm~N|z&YikXC=I0E*8Z$v7PtWfjy*uGFqlA5fnR1Q=q1`;U!~U>|&X_;mk34 zhKqYAO9h_TjRFso_sn|qdUDA33j5IN=@U7M#9uTvV5J{l0zdjRWGKB8J3Uz+|(f(HYHAjk#NQ1jL9! zuha9;i4YYO5J$mewtTo9vVtPTxqXvBInY?m4YD)~h~q$Ax!_EwZpqbZI3OP3;=4xa zULDboazx{;=E*zl0g)CIxiwU0S+taYYlIHHMHZAe8xkWHvSjw;0&`NOTN%Xcr-ivm9Bz1h6 zny%66)ZjF=M6S}>=v4~EuG0F;50<8uJ7@5d0V_2pQVkF7Vq{{!dIm33#3Ft_}G2)yjM)! zd^I{4d6C{M=mM$U&yqhi=!uOq^+sms!NF^^FO?LLY1%(UAAuAQ;Js8WHnK=;BI0?G zj@F^p*@W>;sZ=u3l$xf8pzH;I3P)vOmA?n#aMPBi8 z^%0|sj#w@`5rIzhQ!tSbr|=trz3XA)gH(s7qlZqzSnr3GpT_7Etp6(f@@<&&Cgd6@ zO_{P$>oL!s`$Ftx@?LJr&QNaX8kwntH#$vkYg|R22_$?WFI((Ps;mBgX=;jxe4dv2 zB0W9@Ytx5X>gz7C*}oPKd5d(eNI!)2=dpg8p7eD2T72>A&r(Oc#kZr8Zl0T=_oWh8 z{A0N9vXFPx)*^lID7MGYhmW53!69FY@je$)Lq+<@3s5PVD$*r5``M(QjgmT^@OmO6 z-sp%gHc}rSY5JLvw`8Gz=TflG&)tw(+<*mIXdUgu%{CxCbK8#JowN2@0SO=M^#R!H z6?`{v`CUe5FJ?SwyCTwGaWuckZrbd*cS97n*}$HSL^o`QV`u2{Me=!GI9~_dUxVbO z7s|jzu~fEkS2;SKy+&74sr^v1Sfo!g?rt#d&g0|P1t9ae)DZ7~4AaMp^qVvE1qqxl zUZ9nHsoy&~b@Pi;bSxIXMqg&hucX*B)AZGlZ<_wNNMB2M8@&ts^)Xsm@z<+UH@_KA zm7Vk&{!iU}$6y2}y>=s3q`$h%KQ|De3gWd_T4=Rw*ODsRR%(-Nn7U+pH|>$_UfL(y zBps0LFddieaXJBi>k?^{mF+lLvMtd2WXr!S_d)uoY)gJo;16IEvvuH(Z&YlEF~4Mt zgVERw{mtdnP$YGQLX5QNiKcH()87Fhz);ga;3ro8{wMqZN=5qDvS|E7)4xm6|Cyb+ zfwKtysRw&ATYU!+B2TOXK$*G3l~^PtLwPV-6rR$Fz;;o8z>*(s7WJjAq^m9+Eguv+ z(JTTuX-2FlipGi#>xbCfU@qZdcZ!5pBz#h2ErNo*n((t*0g$hCrXHnm|i`@X6!d0j(RK8a`Hw2l5S1eVl@8los!kPhF(7@ijcCcL%PBB!<=~MKK)m z$2=`T0Eu_#R=NXIH=h{{`4iqLa>{Mu8oi!s7Kf(A;TzGAKje#F5l5QETXFpg?7)M8 zD4Qw*a~?Z-8SK4tke9LDVAp2xFf0l}5RJ{^1U}<`@`|I)B2%(-WLk{fsNVS{3NYNy zg}nR)ue=tyK_MEWlVVgDvV8=;&C^-g=a&0t>2a|ceQr0P|8{y#_POQ$^YjVX=a&1Q zq|36;E%!Nkxz8>4U!u>;KDXTeI(~qWgw0KJD zS&EAzCZPW_^!Tj4^T{T!k9N#2;RO z7iBy{i;&QUo$Tz+nfE#GOwP=ozrTJ1Sc55We021t`blp}YoGj;%5y1uf!uNG{2Uc(N@c!)lX%wI3y3q;Kp>H=-52V;i3A7>>%(TwkwPYfo4kR?qm| z#C16kwWU$vA^EoB6NQd%bM%nHh`l&oU46V-HClA2e;$PpNH>BcwCIK7lE8cr+NK@K zmP_V`PLn)Sf8Dbz3|Fu5lWrRhrFHeWUO$ciK|;QNMYU4B-{xxq=2gh0MJ_>CzIO%I2C`dQ0}U%zLwzhCD9eXj z_~Pck%ya+e`Xnf;1j}62O+JMJ**YJ(mx~=JE+{p9z;saHl6M^@O>uaJ(zL z_pbbfg95AEkMI{PQrP_-wu~WeK)#DjC~RTz1jWl>>J%&u_A8uVq z=X}6rk(Ww~N);x^iv)>V)F>R%WhPu8Gn7lW${nB1g?2dLWg6t73{<@%o=iq^d`ek= z8~x4CS6UNrnFvN?(WJ^CT4hqAYqXBuA|4G-hEb5QoM5x6GZPijL*Z>uQZW67A|R9w^IzUkPhic=6Im%(-`|RxlHTyT__; zTIpHtPB288^%``Bpy}I=`(B1HzbS#S^Q*EAx4u+7Zxc(*~GMtIG z28o~(XLX!G7eiM=)yPxBISPB#v`zndJ?z~G&ZAdH4=ynDG-o(tf4fzG(U*c(G`yvv zwG>!)eOpH#E;0lxhZh*mH;kJ6>$aB=Q(^iUP8ycui3r|Rf%`B(*o|DLxmTuAG{kib zs-%KzVslaWt>u!4${j*dfuna=Gjl-rPoCZgwb{OKc%p z!#g#+w~fKv?Jbb;@C$svFq?dVj~E_foIb8G|l?27Kf`O2bZM(f5T<@B@DC9-<3~{+ae-(qxiFGMiqxGcB za}=}LbSblhT0Q6Rm4>3=gi)o*G!B_6$tq*ItV%e0&U6FU!uj0%!h9}SX6NEZ9}oim zg4WPW?76Hk0#QwuQj$)~3QJw+v|eX=>YZgbHMJs34ZXEzFL($9Pw6>LDO8nGd&N^$ zGQH4GKq$+GsmsL%f7cNR?6y=YGgJHdofV|o;~RKj0^!|%nF=P~ai{JLHLCol`|FQ7a$D7+;JWrBjTd0T_>aUBJK||PoA}xwjpy>>3&$74 zTY?_p_n~D4+YZ_`VA~9v3?#|5p?&G^NcjljeZ~g^f18y^%J9)Cd^>|=NijQzL5oimxJIZx~e9?Ss^Ty`ZaDtBpPPoAsJW(yH z$N4T<;S2#yPeoF?lu&qNOqVhlu1EGea_2aYXH89ap^{o07Yne+0~cxtd5_*)sP&)@HC}ize=e%9#0xj( zimzo}crZ_VtzhsLf5+j%DhiU1%Z6##t_Qui5BGbp8h+wH(WFEnJTC%R=pic)GR)Vx zl-NNqUE8ZG40R2ST?P81rl{~1FV|}^b%`m4HAwH{ZlxUX8MfWq z`a@huNpbS?_U{vkW?z`BxS?f6-*EN3sQFIU*&Zs>w{W~F`=`NPBs+to_fDoY%J&mT za^I?KQr#C`SXEQy8+4CjK)^94+%U%c;)ha|&Us}Dr~V$FH5cA?_7^NiWdTjcKHGad z`(I^x!&*O@E(D3_I~YMN_e-TkVO-5I{WP&CB=atLdm8yB{z!y)5Z#}y(N%%2ER_xg z%>LL&rt(sAn)YO0P%RHU_uED75S80CWtD+%&xu7-w}pRI5;ZHbK<9KYH7;`S)ek+G zpDa%oN5mO>3}rcuKV)=g>f?*8y+^~ny)tY#>h#^uo@~ZRbpFILBs$)PO1o{c;%l~I zD?NNElg^LuV{Zx-9EL76vUNwjEx-8^=V(QVq}1|CC-_?mHD&W!5ytLBA2u@@RT9Q> z5xk@ED^7TkWyJ|qISZjmlqVaglnH+04zh=RgkQyZ`o00KZ%;wtkt+1Zfbr_+Bl+UQ z0k+>A>rBUsjYV?}r=%Kr181gVKe?JMLa*1$T0$f4*>!PStFJpzf09sNh0^E@q; zxxBLnmYcr*boFYk|E>f~ixql>m#wNYJy~kwCc7>jYe_nUIyf?5S*Ly>@l+U_dq?VP z9RTLWnacWtvIs>7Y57AP&h{Dcbq#_pqu_*s8@Sw2vPDmh`9nW)Pd4FdLBXGeCH0r* zFcfC+Xusg2SFr7wr{+65-Pp{>OBG?9PM)97r{om9;MAMK?!5~I9v*`C;{AswcJ~!b zGT&%x5>A3vjHSp+G2O>AlDq$n^NP`!N%izEgie}0Udr!{OnOP@N8JywJ)3jckiV=r zAIy?fcNrQr^K~^JY0~WKh|kScq5ZOLA7*+POIaHo0#m2ExXhm0?<#VZ=$Ug#G@i2T z)Sf$}NrGLS=Y-yWqM|SvSP9zLSvaRuaxJ6M&$r&#-Tk{?9%kpEhxL4t#N{sonv4!M=G<)_BS$lkk!b3IDCH4K>9w(_}s{ z8ddu``20Oov^etWaqOGP*3C+sL}i0^-$R1}G540BRvPNA#!TB~s=UQ$8_)3M}ih#tlN-sc}LQC%Kx^SHHG z|8oV-?{)JR67dyM@{98&y>+wY#<#*y8n4xgB#h;ECrY%I_r-W#892Q7vGc~G{wtig zjXw0drcNP>4zj4bxY4C_vy>^`Y-Rr+Pr?*V^|$zY>>6t8$b4oRv>_=^_+Vk8d`!g5 zY-$%J#Z7eEH0yWLg(Gf;{gb5Gb4L^Z7#|7o&1!BYXOnDC=f&37(Dy_;YU*DaSI4$} zdnSXw0$cR}*#^&pz!P<9Ix+j3>9NqNw+#|^GzK6om9@_?wN)~y&PeA# zqf%KAM72YYcGd{WbE}+khGVVUlmoyn1f_9yf9snxn?~zmZbu&W%K*sAe0FTwP@avv=0APWMrl3v(3BTr=2K50s zLV_s_LTqi823pIlPHNz4?6;r@o;Wlt*%TD~7 zZYWe1GU3lu7)li?gFKog9Px9NMH@s!5^W9E7Fyxu!au9JKafKe0!;GyA83M?YsLHq z)z=Euw;cgF_(706e*KjPNI?hz9AQC#Huz5w3Gcmj3JL)95zq$?oT^+z#KT8+pj0oQ zFEW+mQ5e!lGk{09zHtcvUm>Dll3|e5YK&joh=O{ii+}=hV5p_l2*0-J0;NR$Zm08L zXhndBQ?4$_ Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 014/349] refactor(compose): Extract components from MainScreen --- .../dankchat/main/compose/FloatingToolbar.kt | 367 ++++++++ .../main/compose/FullScreenSheetOverlay.kt | 127 +++ .../flxrs/dankchat/main/compose/MainScreen.kt | 818 +++--------------- .../main/compose/MainScreenDialogs.kt | 299 +++++++ 4 files changed, 903 insertions(+), 708 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt new file mode 100644 index 000000000..022cf50e3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -0,0 +1,367 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +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.interaction.MutableInteractionSource +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.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.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Badge +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +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.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.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FloatingToolbar( + tabState: ChannelTabUiState, + composePagerState: PagerState, + showAppBar: Boolean, + isFullscreen: Boolean, + isLoggedIn: Boolean, + currentStream: com.flxrs.dankchat.data.UserName?, + hasStreamData: Boolean, + streamHeightDp: Dp, + totalMentionCount: Int, + onTabSelected: (Int) -> Unit, + onTabLongClick: (Int) -> Unit, + onAddChannel: () -> Unit, + onOpenMentions: () -> Unit, + // Overflow menu callbacks + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onToggleStream: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + if (tabState.tabs.isEmpty()) return + + val density = LocalDensity.current + val scope = rememberCoroutineScope() + var isTabsExpanded by remember { mutableStateOf(false) } + var showOverflowMenu by remember { mutableStateOf(false) } + var overflowInitialMenu by remember { mutableStateOf(AppBarMenu.Main) } + + val totalTabs = tabState.tabs.size + val hasOverflow = totalTabs > 3 + val selectedIndex = tabState.selectedIndex + + // Expand tabs when pager is swiped in a direction with more channels + LaunchedEffect(composePagerState.isScrollInProgress) { + if (composePagerState.isScrollInProgress && hasOverflow) { + val offset = snapshotFlow { composePagerState.currentPageOffsetFraction } + .first { it != 0f } + val current = composePagerState.currentPage + val swipingForward = offset > 0 + val swipingBackward = offset < 0 + if ((swipingForward && current < totalTabs - 1) || (swipingBackward && current > 0)) { + isTabsExpanded = true + } + } + } + + // Auto-collapse after scroll stops + 2s delay + LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress) { + if (isTabsExpanded && !composePagerState.isScrollInProgress) { + delay(2000) + isTabsExpanded = false + } + } + + // Dismiss scrim for inline overflow menu + if (showOverflowMenu) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + } + ) + } + + AnimatedVisibility( + visible = showAppBar && !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = modifier + .fillMaxWidth() + .padding(top = if (currentStream != null) streamHeightDp else with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .padding(top = 8.dp) + ) { + val tabListState = rememberLazyListState() + + // Auto-scroll to keep selected tab visible + LaunchedEffect(selectedIndex) { + tabListState.animateScrollToItem(selectedIndex) + } + + // Mention indicators based on visibility + val visibleItems = tabListState.layoutInfo.visibleItemsInfo + val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 + val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) + val hasLeftMention = tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } + val hasRightMention = tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.Top + ) { + // Scrollable tabs pill + Surface( + modifier = Modifier.weight(1f, fill = false), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + val mentionGradientColor = MaterialTheme.colorScheme.error + LazyRow( + state = tabListState, + contentPadding = PaddingValues(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 8.dp) + .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 = androidx.compose.ui.geometry.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 = androidx.compose.ui.geometry.Offset(size.width - gradientWidth, 0f), + size = androidx.compose.ui.geometry.Size(gradientWidth, size.height) + ) + } + } + ) { + itemsIndexed( + items = tabState.tabs, + key = { _, tab -> tab.channel.value } + ) { index, tab -> + val isSelected = tab.isSelected + val textColor = when { + isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .combinedClickable( + onClick = { onTabSelected(index) }, + onLongClick = { + onTabLongClick(index) + overflowInitialMenu = AppBarMenu.Channel + showOverflowMenu = true + } + ) + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 12.dp) + ) { + Text( + text = tab.displayName, + color = textColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(4.dp)) + Badge() + } + } + } + } + } + + // Action icons + inline overflow menu (animated with expand/collapse) + AnimatedVisibility( + visible = !isTabsExpanded, + enter = expandHorizontally( + expandFrom = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeIn(tween(200)), + exit = shrinkHorizontally( + shrinkTowards = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeOut(tween(150)) + ) { + Row(verticalAlignment = Alignment.Top) { + Spacer(Modifier.width(8.dp)) + + val pillCornerRadius by animateDpAsState( + targetValue = if (showOverflowMenu) 0.dp else 28.dp, + animationSpec = tween(200), + label = "pillCorner" + ) + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Surface( + shape = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = pillCornerRadius, + bottomEnd = pillCornerRadius + ), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onAddChannel) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_channel) + ) + } + IconButton(onClick = onOpenMentions) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.mentions_title), + tint = if (totalMentionCount > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + ) + } + IconButton(onClick = { + overflowInitialMenu = AppBarMenu.Main + showOverflowMenu = !showOverflowMenu + }) { + 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() + ) { + Surface( + shape = RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomStart = 12.dp, + bottomEnd = 12.dp + ), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + InlineOverflowMenu( + isLoggedIn = isLoggedIn, + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, + onDismiss = { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + }, + initialMenu = overflowInitialMenu, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = onLogout, + onManageChannels = onManageChannels, + onOpenChannel = onOpenChannel, + onRemoveChannel = onRemoveChannel, + onReportChannel = onReportChannel, + onBlockChannel = onBlockChannel, + onReloadEmotes = onReloadEmotes, + onReconnect = onReconnect, + onClearChat = onClearChat, + onToggleStream = onToggleStream, + onOpenSettings = onOpenSettings + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt new file mode 100644 index 000000000..b099e09b4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -0,0 +1,127 @@ +package com.flxrs.dankchat.main.compose + +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.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.user.UserPopupStateParams +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.main.compose.sheets.MentionSheet +import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore + +@Composable +fun FullScreenSheetOverlay( + sheetState: FullScreenSheetState, + isLoggedIn: Boolean, + mentionViewModel: MentionComposeViewModel, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + onDismiss: () -> Unit, + onDismissReplies: () -> Unit, + onUserClick: (UserPopupStateParams) -> Unit, + onMessageLongClick: (MessageOptionsParams) -> Unit, + onEmoteClick: (List) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = sheetState !is FullScreenSheetState.Closed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + val userClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, _ -> + onUserClick( + UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + ) + } + + when (sheetState) { + is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Mention -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = false, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, + onUserClick = userClickHandler, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + ) + }, + onEmoteClick = onEmoteClick + ) + } + is FullScreenSheetState.Whisper -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = true, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, + onUserClick = userClickHandler, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + ) + }, + onEmoteClick = onEmoteClick + ) + } + is FullScreenSheetState.Replies -> { + RepliesSheet( + rootMessageId = sheetState.replyMessageId, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismissReplies, + onUserClick = userClickHandler, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + ) + } + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 80b03665e..7de963354 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -1,81 +1,38 @@ package com.flxrs.dankchat.main.compose import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.clickable -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.unit.DpOffset -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.statusBars -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import com.flxrs.dankchat.main.compose.sheets.EmoteMenu - -import android.content.ClipData import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.background -import androidx.compose.ui.graphics.Brush -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues 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.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.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.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Badge -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingToolbarDefaults -import androidx.compose.material3.HorizontalFloatingToolbar -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +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 @@ -84,83 +41,45 @@ 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.drawWithContent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot -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.LocalFocusManager -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatComposable -import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.message.compose.MessageOptionsState -import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel import com.flxrs.dankchat.chat.user.UserPopupStateParams -import com.flxrs.dankchat.chat.user.compose.UserPopupDialog import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.main.compose.FullScreenSheetState -import com.flxrs.dankchat.main.compose.InputSheetState import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog -import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog -import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog -import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog -import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog -import com.flxrs.dankchat.main.compose.sheets.EmoteMenuSheet -import com.flxrs.dankchat.main.compose.sheets.MentionSheet -import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.main.compose.sheets.EmoteMenu import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.preferences.components.DankBackground import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -183,7 +102,6 @@ fun MainScreen( ) { val context = LocalContext.current val density = LocalDensity.current - val clipboardManager = LocalClipboard.current // Scoped ViewModels - each handles one concern val mainScreenViewModel: MainScreenViewModel = koinViewModel() val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() @@ -199,7 +117,6 @@ fun MainScreen( val mainEventBus: MainEventBus = koinInject() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val uriHandler = LocalUriHandler.current val keyboardController = LocalSoftwareKeyboardController.current val configuration = androidx.compose.ui.platform.LocalConfiguration.current val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE @@ -288,8 +205,6 @@ fun MainScreen( var showRemoveChannelDialog by remember { mutableStateOf(false) } var showBlockChannelDialog by remember { mutableStateOf(false) } var showClearChatDialog by remember { mutableStateOf(false) } - var showOverflowMenu by remember { mutableStateOf(false) } - var overflowInitialMenu by remember { mutableStateOf(AppBarMenu.Main) } var userPopupParams by remember { mutableStateOf(null) } var messageOptionsParams by remember { mutableStateOf(null) } var emoteInfoEmotes by remember { mutableStateOf?>(null) } @@ -347,227 +262,44 @@ fun MainScreen( val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel - if (showAddChannelDialog) { - AddChannelDialog( - onDismiss = { showAddChannelDialog = false }, - onAddChannel = { - channelManagementViewModel.addChannel(it) - showAddChannelDialog = false - } - ) - } - - if (showManageChannelsDialog) { - val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() - ManageChannelsDialog( - channels = channels, - onApplyChanges = channelManagementViewModel::applyChanges, - onDismiss = { showManageChannelsDialog = false } - ) - } - - if (showLogoutDialog) { - AlertDialog( - onDismissRequest = { showLogoutDialog = false }, - title = { Text(stringResource(R.string.confirm_logout_title)) }, - text = { Text(stringResource(R.string.confirm_logout_message)) }, - confirmButton = { - TextButton( - onClick = { - onLogout() - showLogoutDialog = false - } - ) { - Text(stringResource(R.string.confirm_logout_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = { showLogoutDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - val roomStateChannel = inputState.activeChannel - if (showRoomStateDialog && roomStateChannel != null) { - RoomStateDialog( - roomState = channelRepository.getRoomState(roomStateChannel), - onSendCommand = { command -> - chatInputViewModel.trySendMessageOrCommand(command) - }, - onDismiss = { showRoomStateDialog = false } - ) - } - - if (showRemoveChannelDialog && activeChannel != null) { - AlertDialog( - onDismissRequest = { showRemoveChannelDialog = false }, - title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, - text = { Text(stringResource(R.string.confirm_channel_removal_message_named, activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.removeChannel(activeChannel) - showRemoveChannelDialog = false - } - ) { - Text(stringResource(R.string.confirm_channel_removal_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = { showRemoveChannelDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (showBlockChannelDialog && activeChannel != null) { - AlertDialog( - onDismissRequest = { showBlockChannelDialog = false }, - title = { Text(stringResource(R.string.confirm_channel_block_title)) }, - text = { Text(stringResource(R.string.confirm_channel_block_message_named, activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.blockChannel(activeChannel) - showBlockChannelDialog = false - } - ) { - Text(stringResource(R.string.confirm_user_block_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = { showBlockChannelDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (showClearChatDialog && activeChannel != null) { - AlertDialog( - onDismissRequest = { showClearChatDialog = false }, - title = { Text(stringResource(R.string.clear_chat)) }, - text = { Text(stringResource(R.string.confirm_user_delete_message)) }, // Reuse message deletion text or find better one - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.clearChat(activeChannel) - showClearChatDialog = false - } - ) { - Text(stringResource(R.string.dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = { showClearChatDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - messageOptionsParams?.let { params -> - val viewModel: MessageOptionsComposeViewModel = koinViewModel( - key = params.messageId, - parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } - ) - val state by viewModel.state.collectAsStateWithLifecycle() - (state as? MessageOptionsState.Found)?.let { s -> - MessageOptionsDialog( - messageId = s.messageId, - channel = params.channel?.value, - fullMessage = params.fullMessage, - canModerate = s.canModerate, - canReply = s.canReply, - canCopy = params.canCopy, - hasReplyThread = s.hasReplyThread, - onReply = { - chatInputViewModel.setReplying(true, s.messageId, s.replyName) - }, - onViewThread = { - sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) - }, - onCopy = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) - } - }, - onMoreActions = { - sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) - }, - onDelete = viewModel::deleteMessage, - onTimeout = viewModel::timeoutUser, - onBan = viewModel::banUser, - onUnban = viewModel::unbanUser, - onDismiss = { messageOptionsParams = null } - ) - } - } - - emoteInfoEmotes?.let { emotes -> - val viewModel: EmoteInfoComposeViewModel = koinViewModel( - key = emotes.joinToString { it.id }, - parameters = { parametersOf(emotes) } - ) - EmoteInfoDialog( - items = viewModel.items, - onUseEmote = { chatInputViewModel.insertText("$it ") }, - onCopyEmote = { /* TODO: copy to clipboard */ }, - onOpenLink = { onOpenUrl(it) }, - onDismiss = { emoteInfoEmotes = null } - ) - } - - userPopupParams?.let { params -> - val viewModel: UserPopupComposeViewModel = 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) }, - onBlockUser = viewModel::blockUser, - onUnblockUser = viewModel::unblockUser, - onDismiss = { userPopupParams = null }, - onMention = { name, _ -> - chatInputViewModel.insertText("@$name ") - }, - onWhisper = { name -> - chatInputViewModel.updateInputText("/w $name ") - }, - onOpenChannel = { _ -> onOpenChannel() }, - onReport = { _ -> - onReportChannel() - } - ) - } - - if (inputSheetState is InputSheetState.MoreActions) { - val state = inputSheetState as InputSheetState.MoreActions - com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( - messageId = state.messageId, - fullMessage = state.fullMessage, - onCopyFullMessage = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) - } - }, - onCopyMessageId = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_id_copied)) - } - }, - onDismiss = sheetNavigationViewModel::closeInputSheet, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) - } + MainScreenDialogs( + showAddChannelDialog = showAddChannelDialog, + showManageChannelsDialog = showManageChannelsDialog, + showLogoutDialog = showLogoutDialog, + showRoomStateDialog = showRoomStateDialog, + showRemoveChannelDialog = showRemoveChannelDialog, + showBlockChannelDialog = showBlockChannelDialog, + showClearChatDialog = showClearChatDialog, + activeChannel = activeChannel, + roomStateChannel = inputState.activeChannel, + messageOptionsParams = messageOptionsParams, + emoteInfoEmotes = emoteInfoEmotes, + userPopupParams = userPopupParams, + inputSheetState = inputSheetState, + channelManagementViewModel = channelManagementViewModel, + channelRepository = channelRepository, + chatInputViewModel = chatInputViewModel, + sheetNavigationViewModel = sheetNavigationViewModel, + snackbarHostState = snackbarHostState, + onDismissAddChannel = { showAddChannelDialog = false }, + onDismissManageChannels = { showManageChannelsDialog = false }, + onDismissLogout = { showLogoutDialog = false }, + onDismissRoomState = { showRoomStateDialog = false }, + onDismissRemoveChannel = { showRemoveChannelDialog = false }, + onDismissBlockChannel = { showBlockChannelDialog = false }, + onDismissClearChat = { showClearChatDialog = false }, + onDismissMessageOptions = { messageOptionsParams = null }, + onDismissEmoteInfo = { emoteInfoEmotes = null }, + onDismissUserPopup = { userPopupParams = null }, + onLogout = onLogout, + onOpenChannel = onOpenChannel, + onReportChannel = onReportChannel, + onOpenUrl = onOpenUrl, + onAddChannel = { + channelManagementViewModel.addChannel(it) + showAddChannelDialog = false + }, + ) val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() @@ -820,288 +552,49 @@ fun MainScreen( ) // Floating Toolbars - collapsible tabs (expand on swipe) + actions - if (tabState.tabs.isNotEmpty()) { - var isTabsExpanded by remember { mutableStateOf(false) } - - val maxVisibleTabs = 3 - val totalTabs = tabState.tabs.size - val hasOverflow = totalTabs > maxVisibleTabs - val selectedIndex = tabState.selectedIndex - val visibleStartIndex = when { - !hasOverflow -> 0 - selectedIndex <= 0 -> 0 - selectedIndex >= totalTabs - 1 -> maxOf(0, totalTabs - maxVisibleTabs) - else -> selectedIndex - 1 - } - val visibleEndIndex = minOf(visibleStartIndex + maxVisibleTabs, totalTabs) - - // Expand tabs when pager is swiped in a direction with more channels - LaunchedEffect(composePagerState.isScrollInProgress) { - if (composePagerState.isScrollInProgress && hasOverflow) { - // Wait for swipe direction to establish - val offset = snapshotFlow { composePagerState.currentPageOffsetFraction } - .first { it != 0f } - val current = composePagerState.currentPage - val swipingForward = offset > 0 // towards higher index - val swipingBackward = offset < 0 // towards lower index - if ((swipingForward && current < totalTabs - 1) || (swipingBackward && current > 0)) { - isTabsExpanded = true - } - } - } - - // Auto-collapse after scroll stops + 2s delay - LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress) { - if (isTabsExpanded && !composePagerState.isScrollInProgress) { - delay(2000) - isTabsExpanded = false - } - } - - // Dismiss scrim for inline overflow menu - if (showOverflowMenu) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - showOverflowMenu = false - overflowInitialMenu = AppBarMenu.Main - } - ) - } - - AnimatedVisibility( - visible = showAppBar && !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .padding(top = if (currentStream != null) streamHeightDp else with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .padding(top = 8.dp) - ) { - val tabListState = rememberLazyListState() - - // Auto-scroll to keep selected tab visible - LaunchedEffect(selectedIndex) { - tabListState.animateScrollToItem(selectedIndex) - } - - // Mention indicators based on visibility - val visibleItems = tabListState.layoutInfo.visibleItemsInfo - val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 - val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) - val hasLeftMention = tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } - val hasRightMention = tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.Top - ) { - // Scrollable tabs pill - Surface( - modifier = Modifier.weight(1f, fill = false), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - val mentionGradientColor = MaterialTheme.colorScheme.error - LazyRow( - state = tabListState, - contentPadding = PaddingValues(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 8.dp) - .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 = androidx.compose.ui.geometry.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 = androidx.compose.ui.geometry.Offset(size.width - gradientWidth, 0f), - size = androidx.compose.ui.geometry.Size(gradientWidth, size.height) - ) - } - } - ) { - itemsIndexed( - items = tabState.tabs, - key = { _, tab -> tab.channel.value } - ) { index, tab -> - val isSelected = tab.isSelected - val textColor = when { - isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .combinedClickable( - onClick = { - channelTabViewModel.selectTab(index) - scope.launch { composePagerState.scrollToPage(index) } - }, - onLongClick = { - channelTabViewModel.selectTab(index) - scope.launch { composePagerState.scrollToPage(index) } - overflowInitialMenu = AppBarMenu.Channel - showOverflowMenu = true - } - ) - .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 12.dp) - ) { - Text( - text = tab.displayName, - color = textColor, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - if (tab.mentionCount > 0) { - Spacer(Modifier.width(4.dp)) - Badge() - } - } - } - } - } - - // Action icons + inline overflow menu (animated with expand/collapse) - AnimatedVisibility( - visible = !isTabsExpanded, - enter = expandHorizontally( - expandFrom = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeIn(tween(200)), - exit = shrinkHorizontally( - shrinkTowards = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeOut(tween(150)) - ) { - Row(verticalAlignment = Alignment.Top) { - Spacer(Modifier.width(8.dp)) - - val pillCornerRadius by animateDpAsState( - targetValue = if (showOverflowMenu) 0.dp else 28.dp, - animationSpec = tween(200), - label = "pillCorner" - ) - Column(modifier = Modifier.width(IntrinsicSize.Min)) { - Surface( - shape = RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - bottomStart = pillCornerRadius, - bottomEnd = pillCornerRadius - ), - color = MaterialTheme.colorScheme.surfaceContainer - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { showAddChannelDialog = true }) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_channel) - ) - } - IconButton(onClick = { sheetNavigationViewModel.openMentions() }) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.mentions_title), - tint = if (tabState.tabs.sumOf { it.mentionCount } > 0) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - } - ) - } - IconButton(onClick = { - overflowInitialMenu = AppBarMenu.Main - showOverflowMenu = !showOverflowMenu - }) { - 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() - ) { - Surface( - shape = RoundedCornerShape( - topStart = 0.dp, - topEnd = 0.dp, - bottomStart = 12.dp, - bottomEnd = 12.dp - ), - color = MaterialTheme.colorScheme.surfaceContainer - ) { - InlineOverflowMenu( - isLoggedIn = isLoggedIn, - isStreamActive = currentStream != null, - hasStreamData = hasStreamData, - onDismiss = { - showOverflowMenu = false - overflowInitialMenu = AppBarMenu.Main - }, - initialMenu = overflowInitialMenu, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = { showLogoutDialog = true }, - onManageChannels = { showManageChannelsDialog = true }, - onOpenChannel = onOpenChannel, - onRemoveChannel = { showRemoveChannelDialog = true }, - onReportChannel = onReportChannel, - onBlockChannel = { showBlockChannelDialog = true }, - onReloadEmotes = { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() - }, - onReconnect = { - channelManagementViewModel.reconnect() - onReconnect() - }, - onClearChat = { showClearChatDialog = true }, - onToggleStream = { - activeChannel?.let { streamViewModel.toggleStream(it) } - }, - onOpenSettings = onNavigateToSettings - ) - } - } - } - } - } - } - } - } + FloatingToolbar( + tabState = tabState, + composePagerState = composePagerState, + showAppBar = showAppBar, + isFullscreen = isFullscreen, + isLoggedIn = isLoggedIn, + currentStream = currentStream, + hasStreamData = hasStreamData, + streamHeightDp = streamHeightDp, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, + onTabSelected = { index -> + channelTabViewModel.selectTab(index) + scope.launch { composePagerState.scrollToPage(index) } + }, + onTabLongClick = { index -> + channelTabViewModel.selectTab(index) + scope.launch { composePagerState.scrollToPage(index) } + }, + onAddChannel = { showAddChannelDialog = true }, + onOpenMentions = { sheetNavigationViewModel.openMentions() }, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = { showLogoutDialog = true }, + onManageChannels = { showManageChannelsDialog = true }, + onOpenChannel = onOpenChannel, + onRemoveChannel = { showRemoveChannelDialog = true }, + onReportChannel = onReportChannel, + onBlockChannel = { showBlockChannelDialog = true }, + onReloadEmotes = { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + onReloadEmotes() + }, + onReconnect = { + channelManagementViewModel.reconnect() + onReconnect() + }, + onClearChat = { showClearChatDialog = true }, + onToggleStream = { + activeChannel?.let { streamViewModel.toggleStream(it) } + }, + onOpenSettings = onNavigateToSettings, + modifier = Modifier.align(Alignment.TopCenter), + ) // Emote Menu Layer - slides up/down independently of keyboard // Fast tween to match system keyboard animation speed @@ -1134,112 +627,21 @@ fun MainScreen( } // Fullscreen Overlay Sheets - androidx.compose.animation.AnimatedVisibility( - visible = fullScreenSheetState !is FullScreenSheetState.Closed, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = Modifier - .fillMaxSize() - .padding(bottom = sheetBottomPadding) - ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - when (val state = fullScreenSheetState) { - is FullScreenSheetState.Closed -> Unit - is FullScreenSheetState.Mention -> { - MentionSheet( - mentionViewModel = mentionViewModel, - initialisWhisperTab = false, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - } - ) - } - is FullScreenSheetState.Whisper -> { - MentionSheet( - mentionViewModel = mentionViewModel, - initialisWhisperTab = true, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - } - ) - } - - is FullScreenSheetState.Replies -> { - RepliesSheet( - rootMessageId = state.replyMessageId, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = { - sheetNavigationViewModel.closeFullScreenSheet() - chatInputViewModel.setReplying(false) - }, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - } - ) - } - } - } - } + FullScreenSheetOverlay( + sheetState = fullScreenSheetState, + isLoggedIn = isLoggedIn, + mentionViewModel = mentionViewModel, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onDismissReplies = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = { userPopupParams = it }, + onMessageLongClick = { messageOptionsParams = it }, + onEmoteClick = { emoteInfoEmotes = it }, + modifier = Modifier.padding(bottom = sheetBottomPadding), + ) if (showInputState && isKeyboardVisible) { SuggestionDropdown( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt new file mode 100644 index 000000000..ec8fe24a7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -0,0 +1,299 @@ +package com.flxrs.dankchat.main.compose + +import android.content.ClipData +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +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.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.message.compose.MessageOptionsState +import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel +import com.flxrs.dankchat.chat.user.UserPopupStateParams +import com.flxrs.dankchat.chat.user.compose.UserPopupDialog +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.ChannelRepository +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog +import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog +import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog +import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreenDialogs( + showAddChannelDialog: Boolean, + showManageChannelsDialog: Boolean, + showLogoutDialog: Boolean, + showRoomStateDialog: Boolean, + showRemoveChannelDialog: Boolean, + showBlockChannelDialog: Boolean, + showClearChatDialog: Boolean, + activeChannel: UserName?, + roomStateChannel: UserName?, + messageOptionsParams: MessageOptionsParams?, + emoteInfoEmotes: List?, + userPopupParams: UserPopupStateParams?, + inputSheetState: InputSheetState, + channelManagementViewModel: ChannelManagementViewModel, + channelRepository: ChannelRepository, + chatInputViewModel: ChatInputViewModel, + sheetNavigationViewModel: SheetNavigationViewModel, + snackbarHostState: SnackbarHostState, + onDismissAddChannel: () -> Unit, + onDismissManageChannels: () -> Unit, + onDismissLogout: () -> Unit, + onDismissRoomState: () -> Unit, + onDismissRemoveChannel: () -> Unit, + onDismissBlockChannel: () -> Unit, + onDismissClearChat: () -> Unit, + onDismissMessageOptions: () -> Unit, + onDismissEmoteInfo: () -> Unit, + onDismissUserPopup: () -> Unit, + onLogout: () -> Unit, + onOpenChannel: () -> Unit, + onReportChannel: () -> Unit, + onOpenUrl: (String) -> Unit, + onAddChannel: (UserName) -> Unit, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + if (showAddChannelDialog) { + AddChannelDialog( + onDismiss = onDismissAddChannel, + onAddChannel = onAddChannel + ) + } + + if (showManageChannelsDialog) { + val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() + ManageChannelsDialog( + channels = channels, + onApplyChanges = channelManagementViewModel::applyChanges, + onDismiss = onDismissManageChannels + ) + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = onDismissLogout, + title = { Text(stringResource(R.string.confirm_logout_title)) }, + text = { Text(stringResource(R.string.confirm_logout_message)) }, + confirmButton = { + TextButton( + onClick = { + onLogout() + onDismissLogout() + } + ) { + Text(stringResource(R.string.confirm_logout_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismissLogout) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showRoomStateDialog && roomStateChannel != null) { + RoomStateDialog( + roomState = channelRepository.getRoomState(roomStateChannel), + onSendCommand = { command -> + chatInputViewModel.trySendMessageOrCommand(command) + }, + onDismiss = onDismissRoomState + ) + } + + if (showRemoveChannelDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = onDismissRemoveChannel, + title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, + text = { Text(stringResource(R.string.confirm_channel_removal_message_named, activeChannel)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.removeChannel(activeChannel) + onDismissRemoveChannel() + } + ) { + Text(stringResource(R.string.confirm_channel_removal_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRemoveChannel) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showBlockChannelDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = onDismissBlockChannel, + title = { Text(stringResource(R.string.confirm_channel_block_title)) }, + text = { Text(stringResource(R.string.confirm_channel_block_message_named, activeChannel)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.blockChannel(activeChannel) + onDismissBlockChannel() + } + ) { + Text(stringResource(R.string.confirm_user_block_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismissBlockChannel) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showClearChatDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = onDismissClearChat, + title = { Text(stringResource(R.string.clear_chat)) }, + text = { Text(stringResource(R.string.confirm_user_delete_message)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.clearChat(activeChannel) + onDismissClearChat() + } + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissClearChat) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + messageOptionsParams?.let { params -> + val viewModel: MessageOptionsComposeViewModel = koinViewModel( + key = params.messageId, + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } + ) + val state by viewModel.state.collectAsStateWithLifecycle() + (state as? MessageOptionsState.Found)?.let { s -> + MessageOptionsDialog( + messageId = s.messageId, + channel = params.channel?.value, + fullMessage = params.fullMessage, + canModerate = s.canModerate, + canReply = s.canReply, + canCopy = params.canCopy, + hasReplyThread = s.hasReplyThread, + onReply = { + chatInputViewModel.setReplying(true, s.messageId, s.replyName) + }, + onViewThread = { + sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) + }, + onCopy = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onMoreActions = { + sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) + }, + onDelete = viewModel::deleteMessage, + onTimeout = viewModel::timeoutUser, + onBan = viewModel::banUser, + onUnban = viewModel::unbanUser, + onDismiss = onDismissMessageOptions + ) + } + } + + emoteInfoEmotes?.let { emotes -> + val viewModel: EmoteInfoComposeViewModel = koinViewModel( + key = emotes.joinToString { it.id }, + parameters = { parametersOf(emotes) } + ) + EmoteInfoDialog( + items = viewModel.items, + onUseEmote = { chatInputViewModel.insertText("$it ") }, + onCopyEmote = { /* TODO: copy to clipboard */ }, + onOpenLink = { onOpenUrl(it) }, + onDismiss = onDismissEmoteInfo + ) + } + + userPopupParams?.let { params -> + val viewModel: UserPopupComposeViewModel = 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) }, + onBlockUser = viewModel::blockUser, + onUnblockUser = viewModel::unblockUser, + onDismiss = onDismissUserPopup, + onMention = { name, _ -> + chatInputViewModel.insertText("@$name ") + }, + onWhisper = { name -> + chatInputViewModel.updateInputText("/w $name ") + }, + onOpenChannel = { _ -> onOpenChannel() }, + onReport = { _ -> + onReportChannel() + } + ) + } + + if (inputSheetState is InputSheetState.MoreActions) { + val state = inputSheetState as InputSheetState.MoreActions + com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( + messageId = state.messageId, + fullMessage = state.fullMessage, + onCopyFullMessage = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onCopyMessageId = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_id_copied)) + } + }, + onDismiss = sheetNavigationViewModel::closeInputSheet, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) + } +} From 6b9d46f16223faa0c3fe4de20502b348a4c22996 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 015/349] perf(compose): Optimize message rendering and emote handling --- .../dankchat/chat/compose/ChatComposable.kt | 11 ++- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 4 +- .../chat/compose/EmoteAnimationCoordinator.kt | 20 +++++- .../dankchat/chat/compose/StackedEmote.kt | 71 +++++++++++++------ .../compose/TextWithMeasuredInlineContent.kt | 43 ++++++----- .../chat/compose/messages/PrivMessage.kt | 40 +++++++++-- .../compose/messages/WhisperAndRedemption.kt | 36 +++++++++- .../chat/mention/compose/MentionComposable.kt | 12 +++- .../chat/replies/compose/RepliesComposable.kt | 12 +++- 9 files changed, 198 insertions(+), 51 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 625782cda..20eda7bb3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -3,9 +3,12 @@ package com.flxrs.dankchat.chat.compose import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.LocalPlatformContext +import coil3.imageLoader import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -51,7 +54,12 @@ fun ChatComposable( val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) - + + // Create singleton coordinator using the app's ImageLoader (with disk cache, AnimatedImageDecoder, etc.) + val context = LocalPlatformContext.current + val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) + + CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), @@ -69,4 +77,5 @@ fun ChatComposable( contentPadding = contentPadding, onScrollDirectionChanged = onScrollDirectionChanged ) + } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index e80be1a97..a854f65e6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -107,6 +107,8 @@ fun ChatScreen( } } + val reversedMessages = remember(messages) { messages.asReversed() } + Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -125,7 +127,7 @@ fun ChatScreen( } items( - items = messages.asReversed(), + items = reversedMessages, key = { message -> "${message.id}-${message.tag}" }, contentType = { message -> when (message) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt index f24f4eff7..91c52849e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt @@ -7,6 +7,7 @@ import android.util.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import coil3.DrawableImage import coil3.ImageLoader import coil3.PlatformContext @@ -36,9 +37,12 @@ class EmoteAnimationCoordinator( ) { // LruCache for single emote drawables (like badgeCache in EmoteRepository) private val emoteCache = LruCache(256) - + // LruCache for stacked emote drawables (like layerCache in EmoteRepository) private val layerCache = LruCache(128) + + // Cache of known emote dimensions (width, height in px) to avoid layout shifts + val dimensionCache = LruCache>(512) /** * Get or load an emote drawable. @@ -115,12 +119,22 @@ class EmoteAnimationCoordinator( fun clear() { emoteCache.evictAll() layerCache.evictAll() + dimensionCache.evictAll() } } /** - * Provides a singleton EmoteAnimationCoordinator for the composition. - * This ensures all messages share the same coordinator instance. + * CompositionLocal providing a shared EmoteAnimationCoordinator. + * Must be provided at the chat root (e.g., ChatComposable) so all messages + * share the same coordinator and its LruCache. + */ +val LocalEmoteAnimationCoordinator = staticCompositionLocalOf { + error("No EmoteAnimationCoordinator provided. Wrap your chat composables with CompositionLocalProvider.") +} + +/** + * Creates and remembers a singleton EmoteAnimationCoordinator using the given ImageLoader. + * Call this once at the chat root, then provide via [LocalEmoteAnimationCoordinator]. */ @Composable fun rememberEmoteAnimationCoordinator(imageLoader: ImageLoader): EmoteAnimationCoordinator { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt index 9f4159240..b57de195b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt @@ -1,20 +1,17 @@ package com.flxrs.dankchat.chat.compose -import android.graphics.Rect import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable -import android.widget.ImageView 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.ui.Modifier import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import coil3.asDrawable import coil3.compose.LocalPlatformContext import coil3.compose.rememberAsyncImagePainter @@ -69,6 +66,11 @@ fun StackedEmote( // 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 @@ -94,31 +96,35 @@ fun StackedEmote( null } }.toTypedArray() - + if (drawables.isNotEmpty()) { // Create LayerDrawable exactly like old implementation 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) } } - - // Render LayerDrawable if available using rememberAsyncImagePainter - layerDrawableState.value?.let { layerDrawable -> + + 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() } - - // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model val painter = rememberAsyncImagePainter(model = layerDrawable) - + Image( painter = painter, contentDescription = null, @@ -127,6 +133,15 @@ fun StackedEmote( .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() } + ) } } @@ -146,7 +161,10 @@ private fun SingleEmoteDrawable( ) { 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 @@ -164,6 +182,11 @@ private fun SingleEmoteDrawable( // 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 (e: Exception) { @@ -171,22 +194,21 @@ private fun SingleEmoteDrawable( } } } - + // Update animation state when setting changes LaunchedEffect(animateGifs, drawableState.value) { if (drawableState.value is Animatable) { (drawableState.value as Animatable).setRunning(animateGifs) } } - - // Render drawable if available - drawableState.value?.let { drawable -> + + 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() } - - // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model val painter = rememberAsyncImagePainter(model = drawable) - + Image( painter = painter, contentDescription = null, @@ -195,6 +217,15 @@ private fun SingleEmoteDrawable( .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() } + ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index a2a26ee5c..6f047fcd9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -49,6 +49,7 @@ data class EmoteDimensions( * @param text The AnnotatedString with annotations marking where inline content goes * @param inlineContentProviders Map of content IDs to composables that will be measured * @param modifier Modifier for the text + * @param knownDimensions Optional pre-known dimensions for inline content IDs, skipping measurement subcomposition * @param onTextClick Callback for click events with offset position * @param onTextLongClick Callback for long-click events with offset position * @param interactionSource Optional interaction source for ripple effects @@ -58,6 +59,7 @@ fun TextWithMeasuredInlineContent( text: AnnotatedString, inlineContentProviders: Map Unit>, modifier: Modifier = Modifier, + knownDimensions: Map = emptyMap(), onTextClick: ((Int) -> Unit)? = null, onTextLongClick: ((Int) -> Unit)? = null, interactionSource: MutableInteractionSource? = null, @@ -67,28 +69,35 @@ fun TextWithMeasuredInlineContent( val textLayoutResultRef = remember { mutableStateOf(null) } SubcomposeLayout(modifier = modifier) { constraints -> - // Phase 1: Measure all inline content to get actual dimensions + // Phase 1: Measure inline content to get actual dimensions + // Skip measurement for IDs with pre-known dimensions (from cache) val measuredDimensions = mutableMapOf() - + + // Add all pre-known dimensions first + measuredDimensions.putAll(knownDimensions) + + // Only measure items that don't have known dimensions inlineContentProviders.forEach { (id, provider) -> - val measurables = subcompose("measure_$id", provider) - if (measurables.isNotEmpty()) { - // Measure with unbounded constraints to get natural size - val placeable = measurables.first().measure( - Constraints( - maxWidth = constraints.maxWidth, - maxHeight = Constraints.Infinity + if (id !in knownDimensions) { + val measurables = subcompose("measure_$id", provider) + if (measurables.isNotEmpty()) { + // Measure with unbounded constraints to get natural size + val placeable = measurables.first().measure( + Constraints( + maxWidth = constraints.maxWidth, + maxHeight = Constraints.Infinity + ) ) - ) - measuredDimensions[id] = EmoteDimensions( - id = id, - widthPx = placeable.width, - heightPx = placeable.height - ) + measuredDimensions[id] = EmoteDimensions( + id = id, + widthPx = placeable.width, + heightPx = placeable.height + ) + } } } - - // Phase 2: Create InlineTextContent with measured dimensions + + // Phase 2: Create InlineTextContent with measured/known dimensions val inlineContent = measuredDimensions.mapValues { (id, dimensions) -> InlineTextContent( placeholder = Placeholder( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 2a9582d42..8b4bac63f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -38,14 +39,15 @@ import androidx.core.net.toUri import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.chat.compose.EmoteDimensions import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @@ -132,8 +134,7 @@ private fun PrivMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val imageLoader = coil3.ImageLoader.Builder(context).build() - val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) + val emoteCoordinator = LocalEmoteAnimationCoordinator.current val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val nameColor = rememberBackgroundColor(message.lightNameColor, message.darkNameColor) @@ -263,10 +264,41 @@ private fun PrivMessageText( } } + // Compute known dimensions from dimension cache to skip measurement subcomposition + val density = LocalDensity.current + val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { + buildMap { + // Badge dimensions are always known (fixed size) + val badgeSizePx = with(density) { badgeSize.toPx().toInt() } + message.badges.forEach { badge -> + put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) + } + // Emote dimensions from cache + val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + message.emotes.forEach { emote -> + val id = "EMOTE_${emote.code}" + if (emote.urls.size == 1) { + val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } else { + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + val dims = emoteCoordinator.dimensionCache.get(cacheKey) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } + } + } + } + // Use SubcomposeLayout to measure inline content, then render text TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, + knownDimensions = knownDimensions, modifier = Modifier .fillMaxWidth() .alpha(message.textAlpha), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 70ba6039b..42214df92 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -34,13 +35,14 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.EmoteDimensions import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** @@ -89,8 +91,7 @@ private fun WhisperMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val imageLoader = coil3.ImageLoader.Builder(context).build() - val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) + val emoteCoordinator = LocalEmoteAnimationCoordinator.current val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val senderColor = rememberBackgroundColor(message.lightSenderColor, message.darkSenderColor) @@ -213,10 +214,39 @@ private fun WhisperMessageText( } } + // Compute known dimensions from dimension cache to skip measurement subcomposition + val density = LocalDensity.current + val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { + buildMap { + val badgeSizePx = with(density) { badgeSize.toPx().toInt() } + message.badges.forEach { badge -> + put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) + } + val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + message.emotes.forEach { emote -> + val id = "EMOTE_${emote.code}" + if (emote.urls.size == 1) { + val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } else { + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + val dims = emoteCoordinator.dimensionCache.get(cacheKey) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } + } + } + } + // Use SubcomposeLayout to measure inline content, then render text TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, + knownDimensions = knownDimensions, modifier = Modifier.fillMaxWidth(), onTextClick = { offset -> // Handle username clicks diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index 85e000cf7..aafcf92be 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -1,11 +1,16 @@ package com.flxrs.dankchat.chat.mention.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.LocalPlatformContext +import coil3.imageLoader import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -33,7 +38,11 @@ fun MentionComposable( isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) } - + + val context = LocalPlatformContext.current + val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) + + CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), @@ -43,4 +52,5 @@ fun MentionComposable( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick ) + } // CompositionLocalProvider } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index dc0f2eb69..466673f3e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -1,12 +1,17 @@ package com.flxrs.dankchat.chat.replies.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.LocalPlatformContext +import coil3.imageLoader import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.replies.RepliesUiState import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -31,7 +36,11 @@ fun RepliesComposable( ) { val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) - + + val context = LocalPlatformContext.current + val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) + + CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { when (uiState) { is RepliesUiState.Found -> { ChatScreen( @@ -49,4 +58,5 @@ fun RepliesComposable( } } } + } // CompositionLocalProvider } \ No newline at end of file From 839a926c05fdeb9954cdd4797e92c12bc367d192 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 016/349] feat(compose): Add upload, login validation, and snackbar polish --- .../chat/compose/ChatComposeViewModel.kt | 34 ++- .../chat/suggestion/SuggestionProvider.kt | 12 +- .../com/flxrs/dankchat/main/MainActivity.kt | 130 ++++++++- .../com/flxrs/dankchat/main/MainEvent.kt | 10 + .../com/flxrs/dankchat/main/MainFragment.kt | 2 + .../compose/ChannelManagementViewModel.kt | 5 + .../dankchat/main/compose/ChatInputLayout.kt | 15 + .../dankchat/main/compose/FloatingToolbar.kt | 6 + .../flxrs/dankchat/main/compose/MainAppBar.kt | 45 +++ .../flxrs/dankchat/main/compose/MainScreen.kt | 186 ++++++++++--- .../main/compose/MainScreenDialogs.kt | 263 +++++++++++------- 11 files changed, 555 insertions(+), 153 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 2deb9c362..66fe09525 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -8,19 +8,19 @@ import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository 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.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam -import kotlin.time.Duration.Companion.seconds /** * ViewModel for Compose-based chat display. @@ -43,25 +43,41 @@ class ChatComposeViewModel( .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 = HashMap(256) + private var lastAppearanceSettings: AppearanceSettings? = null + private var lastChatSettings: ChatSettings? = null + 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.clear() + lastAppearanceSettings = appearanceSettings + lastChatSettings = chatSettings + } + var messageCount = 0 messages.mapIndexed { index, item -> val isAlternateBackground = when (index) { messages.lastIndex -> messageCount++.isEven else -> (index - messages.size - 1).isEven } + val altBg = isAlternateBackground && appearanceSettings.checkeredMessages + val cacheKey = "${item.message.id}-${item.tag}-$altBg" - item.toChatMessageUiState( - context = context, - appearanceSettings = appearanceSettings, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = isAlternateBackground && appearanceSettings.checkeredMessages - ) + mappingCache.getOrPut(cacheKey) { + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg + ) + } } }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt index f8a149b84..abcc0ae61 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.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.EmoteRepository import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.flow.Flow @@ -18,6 +19,7 @@ import org.koin.core.annotation.Single class SuggestionProvider( private val emoteRepository: EmoteRepository, private val usersRepository: UsersRepository, + private val commandRepository: CommandRepository, private val chatSettingsDataStore: ChatSettingsDataStore, ) { @@ -75,9 +77,13 @@ class SuggestionProvider( } private fun getCommandSuggestions(channel: UserName, constraint: String): Flow> { - // TODO: Implement actual command fetching from CommandRepository - // For now, return empty list - return flowOf(emptyList()) + return combine( + commandRepository.getCommandTriggers(channel), + commandRepository.getSupibotCommands(channel) + ) { triggers, supibotCommands -> + val allCommands = (triggers + supibotCommands).map { Suggestion.CommandSuggestion(it) } + filterCommands(allCommands, constraint) + } } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index d4a1394f9..0ba4c92b4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -2,17 +2,25 @@ package com.flxrs.dankchat.main import android.Manifest import android.annotation.SuppressLint +import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.net.Uri import android.os.Bundle import android.os.IBinder +import android.provider.MediaStore import android.util.Log +import android.webkit.MimeTypeMap 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.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -40,8 +48,10 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.findNavController import androidx.navigation.toRoute +import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R +import com.flxrs.dankchat.ValidationResult import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.notification.NotificationService import com.flxrs.dankchat.data.repo.data.ServiceEvent @@ -67,6 +77,10 @@ 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.theme.DankChatTheme +import com.flxrs.dankchat.data.api.ApiException +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.utils.createMediaFile +import com.flxrs.dankchat.utils.removeExifAttributes import com.flxrs.dankchat.utils.extensions.hasPermission import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu import com.flxrs.dankchat.utils.extensions.isInSupportedPictureInPictureMode @@ -74,12 +88,15 @@ 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.Dispatchers 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 org.koin.compose.viewmodel.koinViewModel +import java.io.IOException class MainActivity : AppCompatActivity() { @@ -87,16 +104,49 @@ class MainActivity : AppCompatActivity() { private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() private val dankChatPreferenceStore: DankChatPreferenceStore by inject() private val mainEventBus: MainEventBus by inject() + private val dataRepository: DataRepository by inject() private val pendingChannelsToClear = mutableListOf() private var navController: NavController? = null private var bindingRef: MainActivityBinding? = null private val binding get() = bindingRef + private var currentMediaUri: Uri = Uri.EMPTY 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 requestImageCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) handleCaptureRequest(imageCapture = true) + } + + private val requestVideoCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.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() var notificationService: NotificationService? = null var isBound = false @@ -164,6 +214,16 @@ class MainActivity : AppCompatActivity() { } private fun setupComposeUi() { + lifecycleScope.launch { + viewModel.validationResult.collect { result -> + when (result) { + is ValidationResult.User -> mainEventBus.emitEvent(MainEvent.LoginValidated(result.username)) + is ValidationResult.IncompleteScopes -> mainEventBus.emitEvent(MainEvent.LoginOutdated(result.username)) + ValidationResult.TokenInvalid -> mainEventBus.emitEvent(MainEvent.LoginTokenInvalid) + ValidationResult.Failure -> mainEventBus.emitEvent(MainEvent.LoginValidationFailed) + } + } + } setContent { DankChatTheme { val navController = rememberNavController() @@ -228,13 +288,13 @@ class MainActivity : AppCompatActivity() { // Handled in MainScreen with ViewModel }, onCaptureImage = { - // TODO: Implement camera capture + startCameraCapture(captureVideo = false) }, onCaptureVideo = { - // TODO: Implement camera capture + startCameraCapture(captureVideo = true) }, onChooseMedia = { - // TODO: Implement media picker + requestGalleryMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) } ) } @@ -561,6 +621,70 @@ class MainActivity : AppCompatActivity() { 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 { + val uri = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", it) + 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(Dispatchers.IO) { + if (imageCapture) { + runCatching { file.removeExifAttributes() } + } + } + val result = withContext(Dispatchers.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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt index 3477786f1..e6e13a7e6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt @@ -1,6 +1,16 @@ package com.flxrs.dankchat.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 LoginValidated(val username: UserName) : MainEvent + data class LoginOutdated(val username: UserName) : MainEvent + data object LoginTokenInvalid : MainEvent + data object LoginValidationFailed : 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 index a3308d2c0..e3354d847 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt @@ -461,6 +461,8 @@ class MainFragment : Fragment() { when (it) { is MainEvent.Error -> handleErrorEvent(it) MainEvent.LogOutRequested -> showLogoutConfirmationDialog() + is MainEvent.UploadSuccess, is MainEvent.UploadFailed, MainEvent.UploadLoading -> Unit + is MainEvent.LoginValidated, is MainEvent.LoginOutdated, MainEvent.LoginTokenInvalid, MainEvent.LoginValidationFailed -> Unit } } collectFlow(channelMentionCount, ::updateChannelMentionBadges) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index 9e6099f3a..bcd1ed21d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -66,9 +66,14 @@ class ChannelManagementViewModel( } fun removeChannel(channel: UserName) { + val wasActive = chatRepository.activeChannel.value == channel preferenceStore.removeChannel(channel) chatRepository.updateChannels(preferenceStore.channels) channelDataCoordinator.cleanupChannel(channel) + + if (wasActive) { + chatRepository.setActiveChannel(preferenceStore.channels.firstOrNull()) + } } fun renameChannel(channel: UserName, displayName: String?) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 1032873fd..3134c4ef1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -79,6 +80,7 @@ fun ChatInputLayout( replyName: UserName?, isEmoteMenuOpen: Boolean, helperText: String?, + isUploading: Boolean, isFullscreen: Boolean, isModerator: Boolean, isStreamActive: Boolean, @@ -211,6 +213,19 @@ fun ChatInputLayout( ) } + // Upload progress indicator + AnimatedVisibility( + visible = isUploading, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + // Actions Row Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index 022cf50e3..158391964 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -93,6 +93,9 @@ fun FloatingToolbar( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, onReloadEmotes: () -> Unit, onReconnect: () -> Unit, onClearChat: () -> Unit, @@ -351,6 +354,9 @@ fun FloatingToolbar( onRemoveChannel = onRemoveChannel, onReportChannel = onReportChannel, onBlockChannel = onBlockChannel, + onCaptureImage = onCaptureImage, + onCaptureVideo = onCaptureVideo, + onChooseMedia = onChooseMedia, onReloadEmotes = onReloadEmotes, onReconnect = onReconnect, onClearChat = onClearChat, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index e27e79545..be10f65eb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -66,6 +66,9 @@ fun MainAppBar( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, onReloadEmotes: () -> Unit, onReconnect: () -> Unit, onClearChat: () -> Unit, @@ -224,6 +227,27 @@ fun MainAppBar( AppBarMenu.Upload -> { SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.take_picture)) }, + onClick = { + onCaptureImage() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.record_video)) }, + onClick = { + onCaptureVideo() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.choose_media)) }, + onClick = { + onChooseMedia() + currentMenu = null + } + ) } AppBarMenu.More -> { @@ -274,6 +298,9 @@ fun ToolbarOverflowMenu( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, onReloadEmotes: () -> Unit, onReconnect: () -> Unit, onClearChat: () -> Unit, @@ -372,6 +399,18 @@ fun ToolbarOverflowMenu( } AppBarMenu.Upload -> { SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.take_picture)) }, + onClick = { onCaptureImage(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.record_video)) }, + onClick = { onCaptureVideo(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.choose_media)) }, + onClick = { onChooseMedia(); onDismiss() } + ) } AppBarMenu.More -> { SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) @@ -430,6 +469,9 @@ fun InlineOverflowMenu( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, onReloadEmotes: () -> Unit, onReconnect: () -> Unit, onClearChat: () -> Unit, @@ -483,6 +525,9 @@ fun InlineOverflowMenu( } AppBarMenu.Upload -> { InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.take_picture)) { onCaptureImage(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.record_video)) { onCaptureVideo(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.choose_media)) { onChooseMedia(); onDismiss() } } AppBarMenu.More -> { InlineSubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 7de963354..e4d51c8a9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -26,13 +26,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult 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 @@ -52,6 +56,7 @@ 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,12 +72,12 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.main.MainEvent -import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.main.compose.sheets.EmoteMenu import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.preferences.components.DankBackground import kotlinx.coroutines.launch import kotlinx.coroutines.flow.debounce @@ -209,8 +214,12 @@ fun MainScreen( var messageOptionsParams by remember { mutableStateOf(null) } var emoteInfoEmotes by remember { mutableStateOf?>(null) } var showRoomStateDialog by remember { mutableStateOf(false) } + var pendingUploadAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var isUploading by remember { mutableStateOf(false) } + var showLoginOutdatedDialog by remember { mutableStateOf(null) } + var showLoginExpiredDialog by remember { mutableStateOf(false) } - val channelRepository: ChannelRepository = koinInject() + val toolsSettingsDataStore: ToolsSettingsDataStore = koinInject() val userStateRepository: UserStateRepository = koinInject() val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() @@ -220,7 +229,43 @@ fun MainScreen( mainEventBus.events.collect { event -> when (event) { is MainEvent.LogOutRequested -> showLogoutDialog = true - else -> Unit + is MainEvent.UploadLoading -> isUploading = true + is MainEvent.UploadSuccess -> { + isUploading = false + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.snackbar_image_uploaded, event.url), + actionLabel = context.getString(R.string.snackbar_paste), + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + chatInputViewModel.insertText(event.url) + } + } + is MainEvent.UploadFailed -> { + isUploading = false + val message = event.errorMessage?.let { context.getString(R.string.snackbar_upload_failed_cause, it) } + ?: context.getString(R.string.snackbar_upload_failed) + snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) + } + is MainEvent.LoginValidated -> { + snackbarHostState.showSnackbar( + message = context.getString(R.string.snackbar_login, event.username), + duration = SnackbarDuration.Short + ) + } + is MainEvent.LoginOutdated -> { + showLoginOutdatedDialog = event.username + } + MainEvent.LoginTokenInvalid -> { + showLoginExpiredDialog = true + } + MainEvent.LoginValidationFailed -> { + snackbarHostState.showSnackbar( + message = context.getString(R.string.oauth_verify_failed), + duration = SnackbarDuration.Short + ) + } + else -> Unit } } } @@ -253,7 +298,8 @@ fun MainScreen( scope.launch { snackbarHostState.showSnackbar( message = state.message, - actionLabel = context.getString(R.string.snackbar_retry) + actionLabel = context.getString(R.string.snackbar_retry), + duration = SnackbarDuration.Long ) } } @@ -263,44 +309,82 @@ fun MainScreen( val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel MainScreenDialogs( - showAddChannelDialog = showAddChannelDialog, - showManageChannelsDialog = showManageChannelsDialog, - showLogoutDialog = showLogoutDialog, - showRoomStateDialog = showRoomStateDialog, - showRemoveChannelDialog = showRemoveChannelDialog, - showBlockChannelDialog = showBlockChannelDialog, - showClearChatDialog = showClearChatDialog, - activeChannel = activeChannel, - roomStateChannel = inputState.activeChannel, - messageOptionsParams = messageOptionsParams, - emoteInfoEmotes = emoteInfoEmotes, - userPopupParams = userPopupParams, - inputSheetState = inputSheetState, - channelManagementViewModel = channelManagementViewModel, - channelRepository = channelRepository, - chatInputViewModel = chatInputViewModel, - sheetNavigationViewModel = sheetNavigationViewModel, + channelState = ChannelDialogState( + showAddChannel = showAddChannelDialog, + showManageChannels = showManageChannelsDialog, + showRemoveChannel = showRemoveChannelDialog, + showBlockChannel = showBlockChannelDialog, + showClearChat = showClearChatDialog, + showRoomState = showRoomStateDialog, + activeChannel = activeChannel, + roomStateChannel = inputState.activeChannel, + onDismissAddChannel = { showAddChannelDialog = false }, + onDismissManageChannels = { showManageChannelsDialog = false }, + onDismissRemoveChannel = { showRemoveChannelDialog = false }, + onDismissBlockChannel = { showBlockChannelDialog = false }, + onDismissClearChat = { showClearChatDialog = false }, + onDismissRoomState = { showRoomStateDialog = false }, + onAddChannel = { + channelManagementViewModel.addChannel(it) + showAddChannelDialog = false + }, + ), + authState = AuthDialogState( + showLogout = showLogoutDialog, + showLoginOutdated = showLoginOutdatedDialog != null, + showLoginExpired = showLoginExpiredDialog, + onDismissLogout = { showLogoutDialog = false }, + onDismissLoginOutdated = { showLoginOutdatedDialog = null }, + onDismissLoginExpired = { showLoginExpiredDialog = false }, + onLogout = onLogout, + onLogin = onLogin, + ), + messageState = MessageInteractionState( + messageOptionsParams = messageOptionsParams, + emoteInfoEmotes = emoteInfoEmotes, + userPopupParams = userPopupParams, + inputSheetState = inputSheetState, + onDismissMessageOptions = { messageOptionsParams = null }, + onDismissEmoteInfo = { emoteInfoEmotes = null }, + onDismissUserPopup = { userPopupParams = null }, + onOpenChannel = onOpenChannel, + onReportChannel = onReportChannel, + onOpenUrl = onOpenUrl, + ), snackbarHostState = snackbarHostState, - onDismissAddChannel = { showAddChannelDialog = false }, - onDismissManageChannels = { showManageChannelsDialog = false }, - onDismissLogout = { showLogoutDialog = false }, - onDismissRoomState = { showRoomStateDialog = false }, - onDismissRemoveChannel = { showRemoveChannelDialog = false }, - onDismissBlockChannel = { showBlockChannelDialog = false }, - onDismissClearChat = { showClearChatDialog = false }, - onDismissMessageOptions = { messageOptionsParams = null }, - onDismissEmoteInfo = { emoteInfoEmotes = null }, - onDismissUserPopup = { userPopupParams = null }, - onLogout = onLogout, - onOpenChannel = onOpenChannel, - onReportChannel = onReportChannel, - onOpenUrl = onOpenUrl, - onAddChannel = { - channelManagementViewModel.addChannel(it) - showAddChannelDialog = false - }, ) + // External hosting upload disclaimer dialog + if (pendingUploadAction != null) { + val uploadHost = remember { + runCatching { + java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host + }.getOrElse { "" } + } + AlertDialog( + onDismissRequest = { pendingUploadAction = null }, + title = { Text(stringResource(R.string.nuuls_upload_title)) }, + text = { Text(stringResource(R.string.external_upload_disclaimer, uploadHost)) }, + confirmButton = { + TextButton( + onClick = { + preferenceStore.hasExternalHostingAcknowledged = true + val action = pendingUploadAction + pendingUploadAction = null + action?.invoke() + } + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { pendingUploadAction = null }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() @@ -343,10 +427,11 @@ fun MainScreen( } } - // Update ViewModel when user swipes - LaunchedEffect(composePagerState.currentPage) { - if (composePagerState.currentPage != pagerState.currentPage) { - channelPagerViewModel.onPageChanged(composePagerState.currentPage) + // Update ViewModel when user swipes (use settledPage to avoid clearing + // unread/mention indicators for pages scrolled through during programmatic jumps) + LaunchedEffect(composePagerState.settledPage) { + if (composePagerState.settledPage != pagerState.currentPage) { + channelPagerViewModel.onPageChanged(composePagerState.settledPage) } } @@ -367,7 +452,10 @@ fun MainScreen( val navBarHeightDp = with(density) { navBars.getBottom(density).toDp() } val totalMenuHeight = targetMenuHeight + navBarHeightDp - val currentImeDp = with(density) { currentImeHeight.toDp() } + // Ignore IME height when a dialog with its own text field is open, + // otherwise the scaffold shifts up behind the dialog unnecessarily. + val hasDialogWithInput = showAddChannelDialog || showRoomStateDialog || showManageChannelsDialog + 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) @@ -389,6 +477,7 @@ fun MainScreen( replyName = inputState.replyName, isEmoteMenuOpen = inputState.isEmoteMenuOpen, helperText = inputState.helperText, + isUploading = isUploading, isFullscreen = isFullscreen, isModerator = userStateRepository.isModeratorInChannel(inputState.activeChannel), isStreamActive = currentStream != null, @@ -580,6 +669,15 @@ fun MainScreen( onRemoveChannel = { showRemoveChannelDialog = true }, onReportChannel = onReportChannel, onBlockChannel = { showBlockChannelDialog = true }, + onCaptureImage = { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else pendingUploadAction = onCaptureImage + }, + onCaptureVideo = { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else pendingUploadAction = onCaptureVideo + }, + onChooseMedia = { + if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else pendingUploadAction = onChooseMedia + }, onReloadEmotes = { activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } onReloadEmotes() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index ec8fe24a7..c04cdf77e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.ClipEntry @@ -24,8 +25,6 @@ import com.flxrs.dankchat.chat.message.compose.MessageOptionsState import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.chat.user.compose.UserPopupDialog -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.ChannelRepository import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @@ -35,169 +34,243 @@ import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog import kotlinx.coroutines.launch +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +@Stable +data class ChannelDialogState( + val showAddChannel: Boolean, + val showManageChannels: Boolean, + val showRemoveChannel: Boolean, + val showBlockChannel: Boolean, + val showClearChat: Boolean, + val showRoomState: Boolean, + val activeChannel: UserName?, + val roomStateChannel: UserName?, + val onDismissAddChannel: () -> Unit, + val onDismissManageChannels: () -> Unit, + val onDismissRemoveChannel: () -> Unit, + val onDismissBlockChannel: () -> Unit, + val onDismissClearChat: () -> Unit, + val onDismissRoomState: () -> Unit, + val onAddChannel: (UserName) -> Unit, +) + +@Stable +data class AuthDialogState( + val showLogout: Boolean, + val showLoginOutdated: Boolean, + val showLoginExpired: Boolean, + val onDismissLogout: () -> Unit, + val onDismissLoginOutdated: () -> Unit, + val onDismissLoginExpired: () -> Unit, + val onLogout: () -> Unit, + val onLogin: () -> Unit, +) + +@Stable +data class MessageInteractionState( + val messageOptionsParams: MessageOptionsParams?, + val emoteInfoEmotes: List?, + val userPopupParams: UserPopupStateParams?, + val inputSheetState: InputSheetState, + val onDismissMessageOptions: () -> Unit, + val onDismissEmoteInfo: () -> Unit, + val onDismissUserPopup: () -> Unit, + val onOpenChannel: () -> Unit, + val onReportChannel: () -> Unit, + val onOpenUrl: (String) -> Unit, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreenDialogs( - showAddChannelDialog: Boolean, - showManageChannelsDialog: Boolean, - showLogoutDialog: Boolean, - showRoomStateDialog: Boolean, - showRemoveChannelDialog: Boolean, - showBlockChannelDialog: Boolean, - showClearChatDialog: Boolean, - activeChannel: UserName?, - roomStateChannel: UserName?, - messageOptionsParams: MessageOptionsParams?, - emoteInfoEmotes: List?, - userPopupParams: UserPopupStateParams?, - inputSheetState: InputSheetState, - channelManagementViewModel: ChannelManagementViewModel, - channelRepository: ChannelRepository, - chatInputViewModel: ChatInputViewModel, - sheetNavigationViewModel: SheetNavigationViewModel, + channelState: ChannelDialogState, + authState: AuthDialogState, + messageState: MessageInteractionState, snackbarHostState: SnackbarHostState, - onDismissAddChannel: () -> Unit, - onDismissManageChannels: () -> Unit, - onDismissLogout: () -> Unit, - onDismissRoomState: () -> Unit, - onDismissRemoveChannel: () -> Unit, - onDismissBlockChannel: () -> Unit, - onDismissClearChat: () -> Unit, - onDismissMessageOptions: () -> Unit, - onDismissEmoteInfo: () -> Unit, - onDismissUserPopup: () -> Unit, - onLogout: () -> Unit, - onOpenChannel: () -> Unit, - onReportChannel: () -> Unit, - onOpenUrl: (String) -> Unit, - onAddChannel: (UserName) -> Unit, ) { val context = LocalContext.current val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() - if (showAddChannelDialog) { + val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() + val chatInputViewModel: ChatInputViewModel = koinViewModel() + val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val channelRepository: ChannelRepository = koinInject() + + // region Channel dialogs + + if (channelState.showAddChannel) { AddChannelDialog( - onDismiss = onDismissAddChannel, - onAddChannel = onAddChannel + onDismiss = channelState.onDismissAddChannel, + onAddChannel = channelState.onAddChannel ) } - if (showManageChannelsDialog) { + if (channelState.showManageChannels) { val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() ManageChannelsDialog( channels = channels, onApplyChanges = channelManagementViewModel::applyChanges, - onDismiss = onDismissManageChannels + onDismiss = channelState.onDismissManageChannels + ) + } + + if (channelState.showRoomState && channelState.roomStateChannel != null) { + RoomStateDialog( + roomState = channelRepository.getRoomState(channelState.roomStateChannel), + onSendCommand = { command -> + chatInputViewModel.trySendMessageOrCommand(command) + }, + onDismiss = channelState.onDismissRoomState ) } - if (showLogoutDialog) { + if (channelState.showRemoveChannel && channelState.activeChannel != null) { AlertDialog( - onDismissRequest = onDismissLogout, - title = { Text(stringResource(R.string.confirm_logout_title)) }, - text = { Text(stringResource(R.string.confirm_logout_message)) }, + onDismissRequest = channelState.onDismissRemoveChannel, + title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, + text = { Text(stringResource(R.string.confirm_channel_removal_message_named, channelState.activeChannel)) }, confirmButton = { TextButton( onClick = { - onLogout() - onDismissLogout() + channelManagementViewModel.removeChannel(channelState.activeChannel) + channelState.onDismissRemoveChannel() } ) { - Text(stringResource(R.string.confirm_logout_positive_button)) + Text(stringResource(R.string.confirm_channel_removal_positive_button)) } }, dismissButton = { - TextButton(onClick = onDismissLogout) { + TextButton(onClick = channelState.onDismissRemoveChannel) { Text(stringResource(R.string.dialog_cancel)) } } ) } - if (showRoomStateDialog && roomStateChannel != null) { - RoomStateDialog( - roomState = channelRepository.getRoomState(roomStateChannel), - onSendCommand = { command -> - chatInputViewModel.trySendMessageOrCommand(command) - }, - onDismiss = onDismissRoomState - ) - } - - if (showRemoveChannelDialog && activeChannel != null) { + if (channelState.showBlockChannel && channelState.activeChannel != null) { AlertDialog( - onDismissRequest = onDismissRemoveChannel, - title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, - text = { Text(stringResource(R.string.confirm_channel_removal_message_named, activeChannel)) }, + onDismissRequest = channelState.onDismissBlockChannel, + title = { Text(stringResource(R.string.confirm_channel_block_title)) }, + text = { Text(stringResource(R.string.confirm_channel_block_message_named, channelState.activeChannel)) }, confirmButton = { TextButton( onClick = { - channelManagementViewModel.removeChannel(activeChannel) - onDismissRemoveChannel() + channelManagementViewModel.blockChannel(channelState.activeChannel) + channelState.onDismissBlockChannel() } ) { - Text(stringResource(R.string.confirm_channel_removal_positive_button)) + Text(stringResource(R.string.confirm_user_block_positive_button)) } }, dismissButton = { - TextButton(onClick = onDismissRemoveChannel) { + TextButton(onClick = channelState.onDismissBlockChannel) { Text(stringResource(R.string.dialog_cancel)) } } ) } - if (showBlockChannelDialog && activeChannel != null) { + if (channelState.showClearChat && channelState.activeChannel != null) { AlertDialog( - onDismissRequest = onDismissBlockChannel, - title = { Text(stringResource(R.string.confirm_channel_block_title)) }, - text = { Text(stringResource(R.string.confirm_channel_block_message_named, activeChannel)) }, + onDismissRequest = channelState.onDismissClearChat, + title = { Text(stringResource(R.string.clear_chat)) }, + text = { Text(stringResource(R.string.confirm_user_delete_message)) }, confirmButton = { TextButton( onClick = { - channelManagementViewModel.blockChannel(activeChannel) - onDismissBlockChannel() + channelManagementViewModel.clearChat(channelState.activeChannel) + channelState.onDismissClearChat() } ) { - Text(stringResource(R.string.confirm_user_block_positive_button)) + Text(stringResource(R.string.dialog_ok)) } }, dismissButton = { - TextButton(onClick = onDismissBlockChannel) { + TextButton(onClick = channelState.onDismissClearChat) { Text(stringResource(R.string.dialog_cancel)) } } ) } - if (showClearChatDialog && activeChannel != null) { + // endregion + + // region Auth dialogs + + if (authState.showLogout) { AlertDialog( - onDismissRequest = onDismissClearChat, - title = { Text(stringResource(R.string.clear_chat)) }, - text = { Text(stringResource(R.string.confirm_user_delete_message)) }, + onDismissRequest = authState.onDismissLogout, + title = { Text(stringResource(R.string.confirm_logout_title)) }, + text = { Text(stringResource(R.string.confirm_logout_message)) }, confirmButton = { TextButton( onClick = { - channelManagementViewModel.clearChat(activeChannel) - onDismissClearChat() + authState.onLogout() + authState.onDismissLogout() } ) { - Text(stringResource(R.string.dialog_ok)) + Text(stringResource(R.string.confirm_logout_positive_button)) } }, dismissButton = { - TextButton(onClick = onDismissClearChat) { + TextButton(onClick = authState.onDismissLogout) { Text(stringResource(R.string.dialog_cancel)) } } ) } - messageOptionsParams?.let { params -> + if (authState.showLoginOutdated) { + AlertDialog( + onDismissRequest = authState.onDismissLoginOutdated, + title = { Text(stringResource(R.string.login_outdated_title)) }, + text = { Text(stringResource(R.string.login_outdated_message)) }, + confirmButton = { + TextButton(onClick = { + authState.onDismissLoginOutdated() + authState.onLogin() + }) { + Text(stringResource(R.string.oauth_expired_login_again)) + } + }, + dismissButton = { + TextButton(onClick = authState.onDismissLoginOutdated) { + Text(stringResource(R.string.dialog_dismiss)) + } + } + ) + } + + if (authState.showLoginExpired) { + AlertDialog( + onDismissRequest = authState.onDismissLoginExpired, + title = { Text(stringResource(R.string.oauth_expired_title)) }, + text = { Text(stringResource(R.string.oauth_expired_message)) }, + confirmButton = { + TextButton(onClick = { + authState.onDismissLoginExpired() + authState.onLogin() + }) { + Text(stringResource(R.string.oauth_expired_login_again)) + } + }, + dismissButton = { + TextButton(onClick = authState.onDismissLoginExpired) { + Text(stringResource(R.string.dialog_dismiss)) + } + } + ) + } + + // endregion + + // region Message interactions + + messageState.messageOptionsParams?.let { params -> val viewModel: MessageOptionsComposeViewModel = koinViewModel( key = params.messageId, parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } @@ -231,12 +304,12 @@ fun MainScreenDialogs( onTimeout = viewModel::timeoutUser, onBan = viewModel::banUser, onUnban = viewModel::unbanUser, - onDismiss = onDismissMessageOptions + onDismiss = messageState.onDismissMessageOptions ) } } - emoteInfoEmotes?.let { emotes -> + messageState.emoteInfoEmotes?.let { emotes -> val viewModel: EmoteInfoComposeViewModel = koinViewModel( key = emotes.joinToString { it.id }, parameters = { parametersOf(emotes) } @@ -245,12 +318,12 @@ fun MainScreenDialogs( items = viewModel.items, onUseEmote = { chatInputViewModel.insertText("$it ") }, onCopyEmote = { /* TODO: copy to clipboard */ }, - onOpenLink = { onOpenUrl(it) }, - onDismiss = onDismissEmoteInfo + onOpenLink = { messageState.onOpenUrl(it) }, + onDismiss = messageState.onDismissEmoteInfo ) } - userPopupParams?.let { params -> + messageState.userPopupParams?.let { params -> val viewModel: UserPopupComposeViewModel = koinViewModel( key = "${params.targetUserId}${params.channel?.value.orEmpty()}", parameters = { parametersOf(params) } @@ -261,25 +334,25 @@ fun MainScreenDialogs( badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }, onBlockUser = viewModel::blockUser, onUnblockUser = viewModel::unblockUser, - onDismiss = onDismissUserPopup, + onDismiss = messageState.onDismissUserPopup, onMention = { name, _ -> chatInputViewModel.insertText("@$name ") }, onWhisper = { name -> chatInputViewModel.updateInputText("/w $name ") }, - onOpenChannel = { _ -> onOpenChannel() }, + onOpenChannel = { _ -> messageState.onOpenChannel() }, onReport = { _ -> - onReportChannel() + messageState.onReportChannel() } ) } - if (inputSheetState is InputSheetState.MoreActions) { - val state = inputSheetState as InputSheetState.MoreActions + val inputSheet = messageState.inputSheetState + if (inputSheet is InputSheetState.MoreActions) { com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( - messageId = state.messageId, - fullMessage = state.fullMessage, + messageId = inputSheet.messageId, + fullMessage = inputSheet.fullMessage, onCopyFullMessage = { scope.launch { clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) @@ -296,4 +369,6 @@ fun MainScreenDialogs( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) } + + // endregion } From 2653d55c39b8470fbb1a8a5e972a54889f36e2c2 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 017/349] fix(compose): Channel management, tabs, and pager improvements --- .../dankchat/chat/compose/ChatComposable.kt | 26 +- .../chat/compose/ChatComposeViewModel.kt | 34 +- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 121 +- .../chat/compose/EmoteAnimationCoordinator.kt | 20 +- .../dankchat/chat/compose/StackedEmote.kt | 71 +- .../compose/TextWithMeasuredInlineContent.kt | 43 +- .../chat/compose/messages/PrivMessage.kt | 40 +- .../compose/messages/WhisperAndRedemption.kt | 36 +- .../chat/mention/compose/MentionComposable.kt | 12 +- .../chat/replies/compose/RepliesComposable.kt | 12 +- .../chat/suggestion/SuggestionProvider.kt | 12 +- .../chat/user/compose/UserPopupDialog.kt | 4 - .../data/repo/stream/StreamDataRepository.kt | 69 -- .../dankchat/domain/ChannelDataLoader.kt | 5 +- .../com/flxrs/dankchat/main/MainActivity.kt | 130 +- .../com/flxrs/dankchat/main/MainEvent.kt | 10 - .../com/flxrs/dankchat/main/MainFragment.kt | 2 - .../compose/ChannelManagementViewModel.kt | 5 - .../dankchat/main/compose/ChatInputLayout.kt | 425 ++----- .../main/compose/ChatInputViewModel.kt | 105 +- .../main/compose/EmptyStateContent.kt | 14 +- .../dankchat/main/compose/FloatingToolbar.kt | 373 ------ .../main/compose/FullScreenSheetOverlay.kt | 127 -- .../flxrs/dankchat/main/compose/MainAppBar.kt | 325 +---- .../flxrs/dankchat/main/compose/MainScreen.kt | 1042 +++++++++-------- .../main/compose/MainScreenDialogs.kt | 374 ------ .../flxrs/dankchat/main/compose/StreamView.kt | 162 --- .../dankchat/main/compose/StreamViewModel.kt | 77 -- .../main/compose/dialogs/RoomStateDialog.kt | 190 --- .../dankchat/main/compose/sheets/EmoteMenu.kt | 163 --- .../preferences/DankChatPreferenceStore.kt | 10 - app/src/main/res/values-de-rDE/strings.xml | 3 - app/src/main/res/values-en/strings.xml | 3 - app/src/main/res/values-es-rES/strings.xml | 15 - app/src/main/res/values-fi-rFI/strings.xml | 1 - app/src/main/res/values-pl-rPL/strings.xml | 26 - app/src/main/res/values-tr-rTR/strings.xml | 15 - app/src/main/res/values/strings.xml | 13 - gradle/libs.versions.toml | 24 +- gradle/wrapper/gradle-wrapper.jar | Bin 46175 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +- gradlew.bat | 3 +- 43 files changed, 768 insertions(+), 3381 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 20eda7bb3..1b61d51eb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -1,14 +1,10 @@ package com.flxrs.dankchat.chat.compose -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.LocalPlatformContext -import coil3.imageLoader import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -35,12 +31,6 @@ fun ChatComposable( onEmoteClick: (List) -> Unit, onReplyClick: (String, UserName) -> Unit, modifier: Modifier = Modifier, - showInput: Boolean = true, - isFullscreen: Boolean = false, - hasHelperText: Boolean = false, - onRecover: () -> Unit = {}, - contentPadding: PaddingValues = PaddingValues(), - onScrollDirectionChanged: (Boolean) -> Unit = {}, ) { // Create ChatComposeViewModel with channel-specific key for proper scoping val viewModel: ChatComposeViewModel = koinViewModel( @@ -54,12 +44,7 @@ fun ChatComposable( val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) - - // Create singleton coordinator using the app's ImageLoader (with disk cache, AnimatedImageDecoder, etc.) - val context = LocalPlatformContext.current - val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) - - CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { + ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), @@ -69,13 +54,6 @@ fun ChatComposable( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick, - showInput = showInput, - isFullscreen = isFullscreen, - hasHelperText = hasHelperText, - onRecover = onRecover, - contentPadding = contentPadding, - onScrollDirectionChanged = onScrollDirectionChanged + onReplyClick = onReplyClick ) - } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 66fe09525..2deb9c362 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -8,19 +8,19 @@ import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository 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.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam +import kotlin.time.Duration.Companion.seconds /** * ViewModel for Compose-based chat display. @@ -43,41 +43,25 @@ class ChatComposeViewModel( .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 = HashMap(256) - private var lastAppearanceSettings: AppearanceSettings? = null - private var lastChatSettings: ChatSettings? = null - 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.clear() - lastAppearanceSettings = appearanceSettings - lastChatSettings = chatSettings - } - var messageCount = 0 messages.mapIndexed { index, item -> val isAlternateBackground = when (index) { messages.lastIndex -> messageCount++.isEven else -> (index - messages.size - 1).isEven } - val altBg = isAlternateBackground && appearanceSettings.checkeredMessages - val cacheKey = "${item.message.id}-${item.tag}-$altBg" - mappingCache.getOrPut(cacheKey) { - item.toChatMessageUiState( - context = context, - appearanceSettings = appearanceSettings, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg - ) - } + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = isAlternateBackground && appearanceSettings.checkeredMessages + ) } }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index a854f65e6..6c3202fa0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -1,32 +1,17 @@ package com.flxrs.dankchat.chat.compose -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.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsBottomHeight 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.FullscreenExit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButton 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,13 +19,12 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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.res.stringResource import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.messages.ModerationMessageComposable import com.flxrs.dankchat.chat.compose.messages.NoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.PointRedemptionMessageComposable @@ -72,19 +56,15 @@ fun ChatScreen( animateGifs: Boolean = true, onEmoteClick: (emotes: List) -> Unit = {}, onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit = { _, _ -> }, - showInput: Boolean = true, - isFullscreen: Boolean = false, - hasHelperText: Boolean = false, - onRecover: () -> Unit = {}, - contentPadding: PaddingValues = PaddingValues(), - onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, ) { val listState = rememberLazyListState() + val scope = rememberCoroutineScope() // Track if we should auto-scroll to bottom (sticky state) + // Use rememberSaveable to survive configuration changes (like theme switches) var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } - // Detect if we're showing the newest messages (with reverseLayout, index 0 = newest) + // Detect if we're showing the newest messages val isAtBottom by remember { derivedStateOf { val firstVisibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() @@ -92,12 +72,11 @@ fun ChatScreen( } } - // Disable auto-scroll when user scrolls forward (up in chat) + // Disable auto-scroll when user scrolls forward (up in the chat) LaunchedEffect(listState.isScrollInProgress) { if (listState.lastScrolledForward && shouldAutoScroll) { shouldAutoScroll = false } - onScrollDirectionChanged(listState.lastScrolledForward) } // Auto-scroll when new messages arrive or when re-enabled @@ -107,8 +86,6 @@ fun ChatScreen( } } - val reversedMessages = remember(messages) { messages.asReversed() } - Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -117,17 +94,10 @@ fun ChatScreen( LazyColumn( state = listState, reverseLayout = true, - contentPadding = contentPadding, modifier = Modifier.fillMaxSize() ) { - if (!showInput && !hasHelperText) { - item(key = "nav-bar-spacer") { - Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) - } - } - items( - items = reversedMessages, + items = messages.asReversed(), key = { message -> "${message.id}-${message.tag}" }, contentType = { message -> when (message) { @@ -159,79 +129,26 @@ fun ChatScreen( } } - // FABs at bottom-end with coordinated position animation - val showScrollFab = !isAtBottom && messages.isNotEmpty() - val fabBottomPadding by animateDpAsState( - targetValue = if (!showInput) 24.dp else 0.dp, - animationSpec = if (showInput) snap() else spring(), - label = "fabBottomPadding" - ) - val recoveryBottomPadding by animateDpAsState( - targetValue = if (showScrollFab) 56.dp + 12.dp else 0.dp, - label = "recoveryBottomPadding" - ) - - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + fabBottomPadding), - contentAlignment = Alignment.BottomEnd - ) { - RecoveryFab( - isFullscreen = isFullscreen, - showInput = showInput, - onRecover = onRecover, - modifier = Modifier.padding(bottom = recoveryBottomPadding) - ) - AnimatedVisibility( - visible = showScrollFab, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), + // Scroll to bottom FAB (show when not at bottom) + if (!isAtBottom && messages.isNotEmpty()) { + FloatingActionButton( + onClick = { + shouldAutoScroll = true + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) ) { - FloatingActionButton( - onClick = { - shouldAutoScroll = true - onScrollDirectionChanged(false) - }, - ) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to bottom" - ) - } + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom" + ) } } } } } -@Composable -private fun RecoveryFab( - isFullscreen: Boolean, - showInput: Boolean, - onRecover: () -> Unit, - modifier: Modifier = Modifier -) { - val visible = isFullscreen || !showInput - AnimatedVisibility( - visible = visible, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - modifier = modifier - ) { - SmallFloatingActionButton( - onClick = onRecover, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) { - Icon( - imageVector = Icons.Default.FullscreenExit, - contentDescription = stringResource(R.string.menu_exit_fullscreen) - ) - } - } -} - /** * Renders a single chat message based on its type */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt index 91c52849e..f24f4eff7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt @@ -7,7 +7,6 @@ import android.util.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf import coil3.DrawableImage import coil3.ImageLoader import coil3.PlatformContext @@ -37,12 +36,9 @@ class EmoteAnimationCoordinator( ) { // LruCache for single emote drawables (like badgeCache in EmoteRepository) private val emoteCache = LruCache(256) - + // LruCache for stacked emote drawables (like layerCache in EmoteRepository) private val layerCache = LruCache(128) - - // Cache of known emote dimensions (width, height in px) to avoid layout shifts - val dimensionCache = LruCache>(512) /** * Get or load an emote drawable. @@ -119,22 +115,12 @@ class EmoteAnimationCoordinator( fun clear() { emoteCache.evictAll() layerCache.evictAll() - dimensionCache.evictAll() } } /** - * CompositionLocal providing a shared EmoteAnimationCoordinator. - * Must be provided at the chat root (e.g., ChatComposable) so all messages - * share the same coordinator and its LruCache. - */ -val LocalEmoteAnimationCoordinator = staticCompositionLocalOf { - error("No EmoteAnimationCoordinator provided. Wrap your chat composables with CompositionLocalProvider.") -} - -/** - * Creates and remembers a singleton EmoteAnimationCoordinator using the given ImageLoader. - * Call this once at the chat root, then provide via [LocalEmoteAnimationCoordinator]. + * Provides a singleton EmoteAnimationCoordinator for the composition. + * This ensures all messages share the same coordinator instance. */ @Composable fun rememberEmoteAnimationCoordinator(imageLoader: ImageLoader): EmoteAnimationCoordinator { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt index b57de195b..9f4159240 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt @@ -1,17 +1,20 @@ package com.flxrs.dankchat.chat.compose +import android.graphics.Rect import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable +import android.widget.ImageView 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.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import coil3.asDrawable import coil3.compose.LocalPlatformContext import coil3.compose.rememberAsyncImagePainter @@ -66,11 +69,6 @@ fun StackedEmote( // 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 @@ -96,35 +94,31 @@ fun StackedEmote( null } }.toTypedArray() - + if (drawables.isNotEmpty()) { // Create LayerDrawable exactly like old implementation 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 + + // Render LayerDrawable if available using rememberAsyncImagePainter + layerDrawableState.value?.let { layerDrawable -> val widthDp = with(density) { layerDrawable.bounds.width().toDp() } val heightDp = with(density) { layerDrawable.bounds.height().toDp() } + + // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model val painter = rememberAsyncImagePainter(model = layerDrawable) - + Image( painter = painter, contentDescription = null, @@ -133,15 +127,6 @@ fun StackedEmote( .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() } - ) } } @@ -161,10 +146,7 @@ private fun SingleEmoteDrawable( ) { 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 @@ -182,11 +164,6 @@ private fun SingleEmoteDrawable( // 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 (e: Exception) { @@ -194,21 +171,22 @@ private fun SingleEmoteDrawable( } } } - + // 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 + + // Render drawable if available + drawableState.value?.let { drawable -> val widthDp = with(density) { drawable.bounds.width().toDp() } val heightDp = with(density) { drawable.bounds.height().toDp() } + + // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model val painter = rememberAsyncImagePainter(model = drawable) - + Image( painter = painter, contentDescription = null, @@ -217,15 +195,6 @@ private fun SingleEmoteDrawable( .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() } - ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index 6f047fcd9..a2a26ee5c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -49,7 +49,6 @@ data class EmoteDimensions( * @param text The AnnotatedString with annotations marking where inline content goes * @param inlineContentProviders Map of content IDs to composables that will be measured * @param modifier Modifier for the text - * @param knownDimensions Optional pre-known dimensions for inline content IDs, skipping measurement subcomposition * @param onTextClick Callback for click events with offset position * @param onTextLongClick Callback for long-click events with offset position * @param interactionSource Optional interaction source for ripple effects @@ -59,7 +58,6 @@ fun TextWithMeasuredInlineContent( text: AnnotatedString, inlineContentProviders: Map Unit>, modifier: Modifier = Modifier, - knownDimensions: Map = emptyMap(), onTextClick: ((Int) -> Unit)? = null, onTextLongClick: ((Int) -> Unit)? = null, interactionSource: MutableInteractionSource? = null, @@ -69,35 +67,28 @@ fun TextWithMeasuredInlineContent( val textLayoutResultRef = remember { mutableStateOf(null) } SubcomposeLayout(modifier = modifier) { constraints -> - // Phase 1: Measure inline content to get actual dimensions - // Skip measurement for IDs with pre-known dimensions (from cache) + // Phase 1: Measure all inline content to get actual dimensions val measuredDimensions = mutableMapOf() - - // Add all pre-known dimensions first - measuredDimensions.putAll(knownDimensions) - - // 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()) { - // Measure with unbounded constraints to get natural size - val placeable = measurables.first().measure( - Constraints( - maxWidth = constraints.maxWidth, - maxHeight = Constraints.Infinity - ) + val measurables = subcompose("measure_$id", provider) + if (measurables.isNotEmpty()) { + // Measure with unbounded constraints to get natural size + val placeable = measurables.first().measure( + Constraints( + maxWidth = constraints.maxWidth, + maxHeight = Constraints.Infinity ) - measuredDimensions[id] = EmoteDimensions( - id = id, - widthPx = placeable.width, - heightPx = placeable.height - ) - } + ) + measuredDimensions[id] = EmoteDimensions( + id = id, + widthPx = placeable.width, + heightPx = placeable.height + ) } } - - // Phase 2: Create InlineTextContent with measured/known dimensions + + // Phase 2: Create InlineTextContent with measured dimensions val inlineContent = measuredDimensions.mapValues { (id, dimensions) -> InlineTextContent( placeholder = Placeholder( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 8b4bac63f..2a9582d42 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -39,15 +38,14 @@ import androidx.core.net.toUri import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.EmoteDimensions +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.EmoteScaling -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote -import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @@ -134,7 +132,8 @@ private fun PrivMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val emoteCoordinator = LocalEmoteAnimationCoordinator.current + val imageLoader = coil3.ImageLoader.Builder(context).build() + val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val nameColor = rememberBackgroundColor(message.lightNameColor, message.darkNameColor) @@ -264,41 +263,10 @@ private fun PrivMessageText( } } - // Compute known dimensions from dimension cache to skip measurement subcomposition - val density = LocalDensity.current - val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { - buildMap { - // Badge dimensions are always known (fixed size) - val badgeSizePx = with(density) { badgeSize.toPx().toInt() } - message.badges.forEach { badge -> - put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) - } - // Emote dimensions from cache - val baseHeight = EmoteScaling.getBaseHeight(fontSize) - val baseHeightPx = with(density) { baseHeight.toPx().toInt() } - message.emotes.forEach { emote -> - val id = "EMOTE_${emote.code}" - if (emote.urls.size == 1) { - val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } else { - val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" - val dims = emoteCoordinator.dimensionCache.get(cacheKey) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } - } - } - } - // Use SubcomposeLayout to measure inline content, then render text TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, - knownDimensions = knownDimensions, modifier = Modifier .fillMaxWidth() .alpha(message.textAlpha), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 42214df92..70ba6039b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -35,14 +34,13 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.EmoteDimensions import com.flxrs.dankchat.chat.compose.EmoteScaling -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** @@ -91,7 +89,8 @@ private fun WhisperMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val emoteCoordinator = LocalEmoteAnimationCoordinator.current + val imageLoader = coil3.ImageLoader.Builder(context).build() + val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val senderColor = rememberBackgroundColor(message.lightSenderColor, message.darkSenderColor) @@ -214,39 +213,10 @@ private fun WhisperMessageText( } } - // Compute known dimensions from dimension cache to skip measurement subcomposition - val density = LocalDensity.current - val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { - buildMap { - val badgeSizePx = with(density) { badgeSize.toPx().toInt() } - message.badges.forEach { badge -> - put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) - } - val baseHeight = EmoteScaling.getBaseHeight(fontSize) - val baseHeightPx = with(density) { baseHeight.toPx().toInt() } - message.emotes.forEach { emote -> - val id = "EMOTE_${emote.code}" - if (emote.urls.size == 1) { - val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } else { - val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" - val dims = emoteCoordinator.dimensionCache.get(cacheKey) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } - } - } - } - // Use SubcomposeLayout to measure inline content, then render text TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, - knownDimensions = knownDimensions, modifier = Modifier.fillMaxWidth(), onTextClick = { offset -> // Handle username clicks diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index aafcf92be..85e000cf7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -1,16 +1,11 @@ package com.flxrs.dankchat.chat.mention.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.LocalPlatformContext -import coil3.imageLoader import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -38,11 +33,7 @@ fun MentionComposable( isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) } - - val context = LocalPlatformContext.current - val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) - - CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { + ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), @@ -52,5 +43,4 @@ fun MentionComposable( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick ) - } // CompositionLocalProvider } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index 466673f3e..dc0f2eb69 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -1,17 +1,12 @@ package com.flxrs.dankchat.chat.replies.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.LocalPlatformContext -import coil3.imageLoader import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.replies.RepliesUiState import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -36,11 +31,7 @@ fun RepliesComposable( ) { val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) - - val context = LocalPlatformContext.current - val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) - - CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { + when (uiState) { is RepliesUiState.Found -> { ChatScreen( @@ -58,5 +49,4 @@ fun RepliesComposable( } } } - } // CompositionLocalProvider } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt index abcc0ae61..f8a149b84 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.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.EmoteRepository import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.flow.Flow @@ -19,7 +18,6 @@ import org.koin.core.annotation.Single class SuggestionProvider( private val emoteRepository: EmoteRepository, private val usersRepository: UsersRepository, - private val commandRepository: CommandRepository, private val chatSettingsDataStore: ChatSettingsDataStore, ) { @@ -77,13 +75,9 @@ class SuggestionProvider( } private fun getCommandSuggestions(channel: UserName, constraint: String): Flow> { - return combine( - commandRepository.getCommandTriggers(channel), - commandRepository.getSupibotCommands(channel) - ) { triggers, supibotCommands -> - val allCommands = (triggers + supibotCommands).map { Suggestion.CommandSuggestion(it) } - filterCommands(allCommands, constraint) - } + // TODO: Implement actual command fetching from CommandRepository + // For now, return empty list + return flowOf(emptyList()) } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index e887341b8..06cd17e2c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.PlainTooltip import androidx.compose.material3.RichTooltip import androidx.compose.material3.Text -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults @@ -254,9 +253,6 @@ private fun UserPopupButton( TextButton( onClick = onClick, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface - ) ) { Row( modifier = Modifier.fillMaxWidth(), 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 deleted file mode 100644 index 45aef8c1b..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.flxrs.dankchat.data.repo.stream - -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.data.DataRepository -import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.main.StreamData -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.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.time.Duration.Companion.seconds - -@Single -class StreamDataRepository( - private val dataRepository: DataRepository, - 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>(emptyList()) - val streamData: StateFlow> = _streamData.asStateFlow() - - fun fetchStreamData(channels: List) { - cancelStreamData() - channels.ifEmpty { return } - - scope.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() - } - - companion object { - private val STREAM_REFRESH_RATE = 30.seconds - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index dccfbd1ec..6aff6637d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -24,7 +24,6 @@ class ChannelDataLoader( private val dataRepository: DataRepository, private val chatRepository: ChatRepository, private val channelRepository: ChannelRepository, - private val getChannelsUseCase: GetChannelsUseCase, private val dispatchersProvider: DispatchersProvider ) { @@ -36,10 +35,8 @@ class ChannelDataLoader( emit(ChannelLoadingState.Loading) try { - // Get channel info - uses GetChannelsUseCase which waits for IRC ROOMSTATE - // if not logged in, matching the legacy MainViewModel.loadData behavior + // Get channel info val channelInfo = channelRepository.getChannel(channel) - ?: getChannelsUseCase(listOf(channel)).firstOrNull() if (channelInfo == null) { emit(ChannelLoadingState.Failed("Channel not found", emptyList())) return@flow diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 0ba4c92b4..d4a1394f9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -2,25 +2,17 @@ package com.flxrs.dankchat.main import android.Manifest import android.annotation.SuppressLint -import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.net.Uri import android.os.Bundle import android.os.IBinder -import android.provider.MediaStore import android.util.Log -import android.webkit.MimeTypeMap 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.appcompat.app.AppCompatActivity -import androidx.core.content.FileProvider -import androidx.core.net.toFile import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -48,10 +40,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.findNavController import androidx.navigation.toRoute -import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R -import com.flxrs.dankchat.ValidationResult import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.notification.NotificationService import com.flxrs.dankchat.data.repo.data.ServiceEvent @@ -77,10 +67,6 @@ 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.theme.DankChatTheme -import com.flxrs.dankchat.data.api.ApiException -import com.flxrs.dankchat.data.repo.data.DataRepository -import com.flxrs.dankchat.utils.createMediaFile -import com.flxrs.dankchat.utils.removeExifAttributes import com.flxrs.dankchat.utils.extensions.hasPermission import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu import com.flxrs.dankchat.utils.extensions.isInSupportedPictureInPictureMode @@ -88,15 +74,12 @@ 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.Dispatchers 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 org.koin.compose.viewmodel.koinViewModel -import java.io.IOException class MainActivity : AppCompatActivity() { @@ -104,49 +87,16 @@ class MainActivity : AppCompatActivity() { private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() private val dankChatPreferenceStore: DankChatPreferenceStore by inject() private val mainEventBus: MainEventBus by inject() - private val dataRepository: DataRepository by inject() private val pendingChannelsToClear = mutableListOf() private var navController: NavController? = null private var bindingRef: MainActivityBinding? = null private val binding get() = bindingRef - private var currentMediaUri: Uri = Uri.EMPTY 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 requestImageCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) handleCaptureRequest(imageCapture = true) - } - - private val requestVideoCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.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() var notificationService: NotificationService? = null var isBound = false @@ -214,16 +164,6 @@ class MainActivity : AppCompatActivity() { } private fun setupComposeUi() { - lifecycleScope.launch { - viewModel.validationResult.collect { result -> - when (result) { - is ValidationResult.User -> mainEventBus.emitEvent(MainEvent.LoginValidated(result.username)) - is ValidationResult.IncompleteScopes -> mainEventBus.emitEvent(MainEvent.LoginOutdated(result.username)) - ValidationResult.TokenInvalid -> mainEventBus.emitEvent(MainEvent.LoginTokenInvalid) - ValidationResult.Failure -> mainEventBus.emitEvent(MainEvent.LoginValidationFailed) - } - } - } setContent { DankChatTheme { val navController = rememberNavController() @@ -288,13 +228,13 @@ class MainActivity : AppCompatActivity() { // Handled in MainScreen with ViewModel }, onCaptureImage = { - startCameraCapture(captureVideo = false) + // TODO: Implement camera capture }, onCaptureVideo = { - startCameraCapture(captureVideo = true) + // TODO: Implement camera capture }, onChooseMedia = { - requestGalleryMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) + // TODO: Implement media picker } ) } @@ -621,70 +561,6 @@ class MainActivity : AppCompatActivity() { 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 { - val uri = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", it) - 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(Dispatchers.IO) { - if (imageCapture) { - runCatching { file.removeExifAttributes() } - } - } - val result = withContext(Dispatchers.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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt index e6e13a7e6..3477786f1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt @@ -1,16 +1,6 @@ package com.flxrs.dankchat.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 LoginValidated(val username: UserName) : MainEvent - data class LoginOutdated(val username: UserName) : MainEvent - data object LoginTokenInvalid : MainEvent - data object LoginValidationFailed : 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 index e3354d847..a3308d2c0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt @@ -461,8 +461,6 @@ class MainFragment : Fragment() { when (it) { is MainEvent.Error -> handleErrorEvent(it) MainEvent.LogOutRequested -> showLogoutConfirmationDialog() - is MainEvent.UploadSuccess, is MainEvent.UploadFailed, MainEvent.UploadLoading -> Unit - is MainEvent.LoginValidated, is MainEvent.LoginOutdated, MainEvent.LoginTokenInvalid, MainEvent.LoginValidationFailed -> Unit } } collectFlow(channelMentionCount, ::updateChannelMentionBadges) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index bcd1ed21d..9e6099f3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -66,14 +66,9 @@ class ChannelManagementViewModel( } fun removeChannel(channel: UserName) { - val wasActive = chatRepository.activeChannel.value == channel preferenceStore.removeChannel(channel) chatRepository.updateChannels(preferenceStore.channels) channelDataCoordinator.cleanupChannel(channel) - - if (wasActive) { - chatRepository.setActiveChannel(preferenceStore.channels.firstOrNull()) - } } fun renameChannel(channel: UserName, displayName: String?) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 3134c4ef1..5c48107f6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,74 +1,64 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -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.slideInVertically +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.indication +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.fillMaxWidth 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.shape.CircleShape 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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions -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.Keyboard -import androidx.compose.material.icons.filled.MoreVert -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.VisibilityOff -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.filled.Repeat import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator 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.material3.ripple import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope 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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -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.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction @Composable fun ChatInputLayout( @@ -78,24 +68,12 @@ fun ChatInputLayout( canSend: Boolean, showReplyOverlay: Boolean, replyName: UserName?, - isEmoteMenuOpen: Boolean, - helperText: String?, - isUploading: Boolean, - isFullscreen: Boolean, - isModerator: Boolean, - isStreamActive: Boolean, - hasStreamData: Boolean, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, onReplyDismiss: () -> Unit, - onToggleFullscreen: () -> Unit, - onToggleInput: () -> Unit, - onToggleStream: () -> Unit, - onChangeRoomState: () -> Unit, modifier: Modifier = Modifier ) { - val focusRequester = remember { FocusRequester() } val hint = when (inputState) { InputState.Default -> stringResource(R.string.hint_connected) InputState.Replying -> stringResource(R.string.hint_replying) @@ -103,298 +81,119 @@ fun ChatInputLayout( 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 - } - - var quickActionsExpanded by remember { androidx.compose.runtime.mutableStateOf(false) } - val topEndRadius by androidx.compose.animation.core.animateDpAsState( - targetValue = if (quickActionsExpanded) 0.dp else 24.dp, - label = "topEndCornerRadius" - ) - - Box(modifier = modifier.fillMaxWidth()) { - Surface( - shape = RoundedCornerShape(topStart = 24.dp, topEnd = topEndRadius), - color = surfaceColor, - modifier = Modifier.fillMaxWidth() + Surface( + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() + // Reply Header + AnimatedVisibility( + visible = showReplyOverlay && replyName != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() ) { - // Reply Header - AnimatedVisibility( - visible = showReplyOverlay && replyName != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - 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 = stringResource(R.string.reply_header, replyName?.value.orEmpty()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - IconButton( - onClick = onReplyDismiss, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.dialog_dismiss), - modifier = Modifier.size(16.dp) - ) - } - } - HorizontalDivider( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - modifier = Modifier.padding(horizontal = 16.dp) - ) - } - } - - // Text Field - TextField( - state = textFieldState, - enabled = enabled, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .padding(bottom = 0.dp), // Reduce bottom padding as actions are below - label = { Text(hint) }, - colors = textFieldColors, - shape = RoundedCornerShape(0.dp), - lineLimits = TextFieldLineLimits.MultiLine( - minHeightInLines = 1, - maxHeightInLines = 5 - ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - onKeyboardAction = { if (canSend) onSend() } - ) - - // Helper text (roomstate + live info) - AnimatedVisibility( - visible = !helperText.isNullOrEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Text( - text = helperText.orEmpty(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 4.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Start - ) - } - - // Upload progress indicator - AnimatedVisibility( - visible = isUploading, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - LinearProgressIndicator( + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - ) - } - - // Actions Row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) - ) { - // Emote/Keyboard Button (Left) - IconButton( - onClick = { - if (isEmoteMenuOpen) { - focusRequester.requestFocus() - } - onEmoteClick() - }, - enabled = enabled, - modifier = Modifier.size(40.dp) + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp) ) { - if (isEmoteMenuOpen) { + Text( + text = stringResource(R.string.reply_header, replyName?.value.orEmpty()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + IconButton( + onClick = onReplyDismiss, + modifier = Modifier.size(24.dp) + ) { Icon( - imageVector = Icons.Default.Keyboard, + imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.dialog_dismiss), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - Icon( - imageVector = Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.emote_menu_hint), - tint = MaterialTheme.colorScheme.onSurfaceVariant + modifier = Modifier.size(16.dp) ) } } - - Spacer(modifier = Modifier.weight(1f)) - - // Quick Actions Button - IconButton( - onClick = { quickActionsExpanded = !quickActionsExpanded }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // History Button (Always visible) - IconButton( - onClick = onLastMessageClick, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.History, - contentDescription = stringResource(R.string.resume_scroll), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Send Button (Right) - SendButton( - enabled = canSend, - onSend = onSend, - modifier = Modifier + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(horizontal = 16.dp) ) } } - } - - // Quick actions menu — Popup with custom positioning and slide animation - val menuVisibleState = remember { MutableTransitionState(false) } - menuVisibleState.targetState = quickActionsExpanded - if (menuVisibleState.currentState || menuVisibleState.targetState) { - val positionProvider = remember { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset = IntOffset( - x = anchorBounds.right - popupContentSize.width, - y = anchorBounds.top - popupContentSize.height + // Text Field + TextField( + state = textFieldState, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 0.dp), // Reduce bottom padding as actions are below + label = { Text(hint) }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + shape = RoundedCornerShape(0.dp), + lineLimits = TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = 5 + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + onKeyboardAction = { if (canSend) onSend() } + ) + + // Actions Row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + ) { + // Emote Button (Left) + IconButton( + onClick = onEmoteClick, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = stringResource(R.string.emote_menu_hint), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - Popup( - popupPositionProvider = positionProvider, - onDismissRequest = { quickActionsExpanded = false }, - properties = PopupProperties(focusable = true), - ) { - AnimatedVisibility( - visibleState = menuVisibleState, - enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = tween(durationMillis = 150) - ) + fadeIn(animationSpec = tween(durationMillis = 100)), - exit = shrinkVertically( - shrinkTowards = Alignment.Bottom, - animationSpec = tween(durationMillis = 120) - ) + fadeOut(animationSpec = tween(durationMillis = 80)), + Spacer(modifier = Modifier.weight(1f)) + + // History Button (Always visible) + IconButton( + onClick = onLastMessageClick, + enabled = enabled, + modifier = Modifier.size(40.dp) ) { - Surface( - shape = RoundedCornerShape(topStart = 12.dp), - color = surfaceColor, - ) { - Column(modifier = Modifier.width(IntrinsicSize.Max)) { - if (hasStreamData || isStreamActive) { - DropdownMenuItem( - text = { Text(stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) }, - onClick = { - onToggleStream() - quickActionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, - contentDescription = null - ) - } - ) - } - DropdownMenuItem( - text = { Text(stringResource(if (isFullscreen) R.string.menu_exit_fullscreen else R.string.menu_fullscreen)) }, - onClick = { - onToggleFullscreen() - quickActionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, - contentDescription = null - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_hide_input)) }, - onClick = { - onToggleInput() - quickActionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.VisibilityOff, - contentDescription = null - ) - } - ) - if (isModerator) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_room_state)) }, - onClick = { - onChangeRoomState() - quickActionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Shield, - contentDescription = null - ) - } - ) - } - } - } + Icon( + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.resume_scroll), // Using resume_scroll as a placeholder for "History/Last message" context + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + + // Send Button (Right) + SendButton( + enabled = canSend, + onSend = onSend, + modifier = Modifier + ) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index ee96a74d3..e38595d57 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -13,7 +13,6 @@ 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.stream.StreamDataRepository import com.flxrs.dankchat.data.repo.command.CommandResult import com.flxrs.dankchat.data.twitch.chat.ConnectionState import com.flxrs.dankchat.data.twitch.command.TwitchCommand @@ -22,23 +21,18 @@ import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.main.RepeatedSendData import com.flxrs.dankchat.main.compose.FullScreenSheetState 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.stream.StreamsSettingsDataStore import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job 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 @@ -54,10 +48,7 @@ class ChatInputViewModel( private val userStateRepository: UserStateRepository, private val suggestionProvider: SuggestionProvider, private val preferenceStore: DankChatPreferenceStore, - private val chatSettingsDataStore: ChatSettingsDataStore, private val developerSettingsDataStore: DeveloperSettingsDataStore, - private val streamDataRepository: StreamDataRepository, - private val streamsSettingsDataStore: StreamsSettingsDataStore, private val mainEventBus: MainEventBus, ) : ViewModel() { @@ -71,8 +62,6 @@ class ChatInputViewModel( private val repeatedSend = MutableStateFlow(RepeatedSendData(enabled = false, message = "")) private val fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) private val mentionSheetTab = MutableStateFlow(0) - private val _isEmoteMenuOpen = MutableStateFlow(false) - val isEmoteMenuOpen = _isEmoteMenuOpen.asStateFlow() // Create flow from TextFieldState private val textFlow = snapshotFlow { textFieldState.text.toString() } @@ -90,44 +79,6 @@ class ChatInputViewModel( suggestionProvider.getSuggestions(text, channel) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - private val roomStateDisplayText: StateFlow = combine( - chatSettingsDataStore.showChatModes, - chatRepository.activeChannel - ) { showModes, channel -> - showModes to channel - }.flatMapLatest { (showModes, channel) -> - if (!showModes || channel == null) flowOf(null) - else channelRepository.getRoomStateFlow(channel).map { it.toDisplayText().ifEmpty { null } } - }.distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - - private val currentStreamInfo: StateFlow = combine( - streamsSettingsDataStore.showStreamsInfo, - chatRepository.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( - roomStateDisplayText, - currentStreamInfo - ) { roomState, streamInfo -> - listOfNotNull(roomState, streamInfo) - .joinToString(separator = " - ") - .ifEmpty { null } - }.distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - - val hasStreamData: StateFlow = combine( - chatRepository.activeChannel, - streamDataRepository.streamData - ) { activeChannel, streamData -> - activeChannel != null && streamData.any { it.channel == activeChannel } - }.distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - private var _uiState: StateFlow? = null init { @@ -149,20 +100,6 @@ class ChatInputViewModel( } } } - - // Trigger stream data fetching whenever channels change - viewModelScope.launch { - chatRepository.channels.collect { channels -> - if (channels != null) { - streamDataRepository.fetchStreamData(channels) - } - } - } - } - - override fun onCleared() { - super.onCleared() - streamDataRepository.cancelStreamData() } private data class UiDependencies( @@ -178,8 +115,7 @@ class ChatInputViewModel( val tab: Int, val isReplying: Boolean, val replyName: UserName?, - val replyMessageId: String?, - val isEmoteMenuOpen: Boolean + val replyMessageId: String? ) fun uiState(fullScreenSheetState: StateFlow, mentionSheetTab: StateFlow): StateFlow { @@ -198,29 +134,20 @@ class ChatInputViewModel( UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn) } - val replyStateFlow = combine( - _isReplying, - _replyName, - _replyMessageId - ) { isReplying, replyName, replyMessageId -> - Triple(isReplying, replyName, replyMessageId) - } - val sheetAndReplyFlow = combine( fullScreenSheetState, mentionSheetTab, - replyStateFlow, - _isEmoteMenuOpen - ) { sheetState, tab, replyState, isEmoteMenuOpen -> - val (isReplying, replyName, replyMessageId) = replyState - SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen) + _isReplying, + _replyName, + _replyMessageId + ) { sheetState, tab, isReplying, replyName, replyMessageId -> + SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId) } _uiState = combine( baseFlow, - sheetAndReplyFlow, - helperText - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen), helperText -> + sheetAndReplyFlow + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId) -> this.fullScreenSheetState.value = sheetState this.mentionSheetTab.value = tab @@ -254,9 +181,7 @@ class ChatInputViewModel( inputState = inputState, showReplyOverlay = showReplyOverlay, replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, - replyName = effectiveReplyName, - isEmoteMenuOpen = isEmoteMenuOpen, - helperText = helperText + replyName = effectiveReplyName ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) @@ -389,14 +314,6 @@ class ChatInputViewModel( } } - fun toggleEmoteMenu() { - _isEmoteMenuOpen.update { !it } - } - - fun setEmoteMenuOpen(open: Boolean) { - _isEmoteMenuOpen.value = open - } - companion object { private const val SUGGESTION_DEBOUNCE_MS = 20L } @@ -414,6 +331,4 @@ data class ChatInputUiState( val showReplyOverlay: Boolean = false, val replyMessageId: String? = null, val replyName: UserName? = null, - val isEmoteMenuOpen: Boolean = false, - val helperText: String? = null -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt index 5b838c8f9..043983b59 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt @@ -30,6 +30,8 @@ fun EmptyStateContent( onAddChannel: () -> Unit, onLogin: () -> Unit, onToggleAppBar: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, modifier: Modifier = Modifier ) { Surface(modifier = modifier) { @@ -68,7 +70,17 @@ fun EmptyStateContent( AssistChip( onClick = onToggleAppBar, - label = { Text("Toggle App Bar") } + label = { Text("Toggle App Bar") } // Consider using resources + ) + + AssistChip( + onClick = onToggleFullscreen, + label = { Text("Toggle Fullscreen") } + ) + + AssistChip( + onClick = onToggleInput, + label = { Text("Toggle Input") } ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt deleted file mode 100644 index 158391964..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ /dev/null @@ -1,373 +0,0 @@ -package com.flxrs.dankchat.main.compose - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -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.interaction.MutableInteractionSource -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.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.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.Badge -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -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.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.drawWithContent -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.R -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun FloatingToolbar( - tabState: ChannelTabUiState, - composePagerState: PagerState, - showAppBar: Boolean, - isFullscreen: Boolean, - isLoggedIn: Boolean, - currentStream: com.flxrs.dankchat.data.UserName?, - hasStreamData: Boolean, - streamHeightDp: Dp, - totalMentionCount: Int, - onTabSelected: (Int) -> Unit, - onTabLongClick: (Int) -> Unit, - onAddChannel: () -> Unit, - onOpenMentions: () -> Unit, - // Overflow menu callbacks - onLogin: () -> Unit, - onRelogin: () -> Unit, - onLogout: () -> Unit, - onManageChannels: () -> Unit, - onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, - onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, - onClearChat: () -> Unit, - onToggleStream: () -> Unit, - onOpenSettings: () -> Unit, - modifier: Modifier = Modifier, -) { - if (tabState.tabs.isEmpty()) return - - val density = LocalDensity.current - val scope = rememberCoroutineScope() - var isTabsExpanded by remember { mutableStateOf(false) } - var showOverflowMenu by remember { mutableStateOf(false) } - var overflowInitialMenu by remember { mutableStateOf(AppBarMenu.Main) } - - val totalTabs = tabState.tabs.size - val hasOverflow = totalTabs > 3 - val selectedIndex = tabState.selectedIndex - - // Expand tabs when pager is swiped in a direction with more channels - LaunchedEffect(composePagerState.isScrollInProgress) { - if (composePagerState.isScrollInProgress && hasOverflow) { - val offset = snapshotFlow { composePagerState.currentPageOffsetFraction } - .first { it != 0f } - val current = composePagerState.currentPage - val swipingForward = offset > 0 - val swipingBackward = offset < 0 - if ((swipingForward && current < totalTabs - 1) || (swipingBackward && current > 0)) { - isTabsExpanded = true - } - } - } - - // Auto-collapse after scroll stops + 2s delay - LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress) { - if (isTabsExpanded && !composePagerState.isScrollInProgress) { - delay(2000) - isTabsExpanded = false - } - } - - // Dismiss scrim for inline overflow menu - if (showOverflowMenu) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - showOverflowMenu = false - overflowInitialMenu = AppBarMenu.Main - } - ) - } - - AnimatedVisibility( - visible = showAppBar && !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), - modifier = modifier - .fillMaxWidth() - .padding(top = if (currentStream != null) streamHeightDp else with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .padding(top = 8.dp) - ) { - val tabListState = rememberLazyListState() - - // Auto-scroll to keep selected tab visible - LaunchedEffect(selectedIndex) { - tabListState.animateScrollToItem(selectedIndex) - } - - // Mention indicators based on visibility - val visibleItems = tabListState.layoutInfo.visibleItemsInfo - val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 - val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) - val hasLeftMention = tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } - val hasRightMention = tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.Top - ) { - // Scrollable tabs pill - Surface( - modifier = Modifier.weight(1f, fill = false), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - val mentionGradientColor = MaterialTheme.colorScheme.error - LazyRow( - state = tabListState, - contentPadding = PaddingValues(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 8.dp) - .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 = androidx.compose.ui.geometry.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 = androidx.compose.ui.geometry.Offset(size.width - gradientWidth, 0f), - size = androidx.compose.ui.geometry.Size(gradientWidth, size.height) - ) - } - } - ) { - itemsIndexed( - items = tabState.tabs, - key = { _, tab -> tab.channel.value } - ) { index, tab -> - val isSelected = tab.isSelected - val textColor = when { - isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .combinedClickable( - onClick = { onTabSelected(index) }, - onLongClick = { - onTabLongClick(index) - overflowInitialMenu = AppBarMenu.Channel - showOverflowMenu = true - } - ) - .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 12.dp) - ) { - Text( - text = tab.displayName, - color = textColor, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - if (tab.mentionCount > 0) { - Spacer(Modifier.width(4.dp)) - Badge() - } - } - } - } - } - - // Action icons + inline overflow menu (animated with expand/collapse) - AnimatedVisibility( - visible = !isTabsExpanded, - enter = expandHorizontally( - expandFrom = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeIn(tween(200)), - exit = shrinkHorizontally( - shrinkTowards = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeOut(tween(150)) - ) { - Row(verticalAlignment = Alignment.Top) { - Spacer(Modifier.width(8.dp)) - - val pillCornerRadius by animateDpAsState( - targetValue = if (showOverflowMenu) 0.dp else 28.dp, - animationSpec = tween(200), - label = "pillCorner" - ) - Column(modifier = Modifier.width(IntrinsicSize.Min)) { - Surface( - shape = RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - bottomStart = pillCornerRadius, - bottomEnd = pillCornerRadius - ), - color = MaterialTheme.colorScheme.surfaceContainer - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onAddChannel) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_channel) - ) - } - IconButton(onClick = onOpenMentions) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.mentions_title), - tint = if (totalMentionCount > 0) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - } - ) - } - IconButton(onClick = { - overflowInitialMenu = AppBarMenu.Main - showOverflowMenu = !showOverflowMenu - }) { - 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() - ) { - Surface( - shape = RoundedCornerShape( - topStart = 0.dp, - topEnd = 0.dp, - bottomStart = 12.dp, - bottomEnd = 12.dp - ), - color = MaterialTheme.colorScheme.surfaceContainer - ) { - InlineOverflowMenu( - isLoggedIn = isLoggedIn, - isStreamActive = currentStream != null, - hasStreamData = hasStreamData, - onDismiss = { - showOverflowMenu = false - overflowInitialMenu = AppBarMenu.Main - }, - initialMenu = overflowInitialMenu, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = onLogout, - onManageChannels = onManageChannels, - onOpenChannel = onOpenChannel, - onRemoveChannel = onRemoveChannel, - onReportChannel = onReportChannel, - onBlockChannel = onBlockChannel, - onCaptureImage = onCaptureImage, - onCaptureVideo = onCaptureVideo, - onChooseMedia = onChooseMedia, - onReloadEmotes = onReloadEmotes, - onReconnect = onReconnect, - onClearChat = onClearChat, - onToggleStream = onToggleStream, - onOpenSettings = onOpenSettings - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt deleted file mode 100644 index b099e09b4..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.flxrs.dankchat.main.compose - -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.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.user.UserPopupStateParams -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.main.compose.sheets.MentionSheet -import com.flxrs.dankchat.main.compose.sheets.RepliesSheet -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore - -@Composable -fun FullScreenSheetOverlay( - sheetState: FullScreenSheetState, - isLoggedIn: Boolean, - mentionViewModel: MentionComposeViewModel, - appearanceSettingsDataStore: AppearanceSettingsDataStore, - onDismiss: () -> Unit, - onDismissReplies: () -> Unit, - onUserClick: (UserPopupStateParams) -> Unit, - onMessageLongClick: (MessageOptionsParams) -> Unit, - onEmoteClick: (List) -> Unit, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = sheetState !is FullScreenSheetState.Closed, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = modifier.fillMaxSize() - ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - val userClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, _ -> - onUserClick( - UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - ) - } - - when (sheetState) { - is FullScreenSheetState.Closed -> Unit - is FullScreenSheetState.Mention -> { - MentionSheet( - mentionViewModel = mentionViewModel, - initialisWhisperTab = false, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = onDismiss, - onUserClick = userClickHandler, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageLongClick( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false - ) - ) - }, - onEmoteClick = onEmoteClick - ) - } - is FullScreenSheetState.Whisper -> { - MentionSheet( - mentionViewModel = mentionViewModel, - initialisWhisperTab = true, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = onDismiss, - onUserClick = userClickHandler, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageLongClick( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false - ) - ) - }, - onEmoteClick = onEmoteClick - ) - } - is FullScreenSheetState.Replies -> { - RepliesSheet( - rootMessageId = sheetState.replyMessageId, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = onDismissReplies, - onUserClick = userClickHandler, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageLongClick( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - ) - } - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index be10f65eb..fbbb3935d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -7,16 +7,9 @@ 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.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.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications @@ -35,14 +28,10 @@ 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.Shape import androidx.compose.ui.res.stringResource -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R -sealed interface AppBarMenu { +private sealed interface AppBarMenu { data object Main : AppBarMenu data object Account : AppBarMenu data object Channel : AppBarMenu @@ -66,9 +55,6 @@ fun MainAppBar( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, onReloadEmotes: () -> Unit, onReconnect: () -> Unit, onClearChat: () -> Unit, @@ -227,27 +213,6 @@ fun MainAppBar( AppBarMenu.Upload -> { SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.take_picture)) }, - onClick = { - onCaptureImage() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.record_video)) }, - onClick = { - onCaptureVideo() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.choose_media)) }, - onClick = { - onChooseMedia() - currentMenu = null - } - ) } AppBarMenu.More -> { @@ -274,7 +239,7 @@ fun MainAppBar( } ) } - + null -> {} } } @@ -285,164 +250,15 @@ fun MainAppBar( ) } -@Composable -fun ToolbarOverflowMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isLoggedIn: Boolean, - onLogin: () -> Unit, - onRelogin: () -> Unit, - onLogout: () -> Unit, - onManageChannels: () -> Unit, - onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, - onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, - onClearChat: () -> Unit, - onOpenSettings: () -> Unit, - shape: Shape = MaterialTheme.shapes.medium, - offset: DpOffset = DpOffset.Zero, -) { - var currentMenu by remember { mutableStateOf(AppBarMenu.Main) } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { - onDismiss() - currentMenu = AppBarMenu.Main - }, - shape = shape, - offset = offset - ) { - 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 = "MenuTransition" - ) { menu -> - Column { - when (menu) { - AppBarMenu.Main -> { - if (!isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.login)) }, - onClick = { onLogin(); onDismiss() } - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.account)) }, - onClick = { currentMenu = AppBarMenu.Account } - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.manage_channels)) }, - onClick = { onManageChannels(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.channel)) }, - onClick = { currentMenu = AppBarMenu.Channel } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.upload_media)) }, - onClick = { currentMenu = AppBarMenu.Upload } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.more)) }, - onClick = { currentMenu = AppBarMenu.More } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.settings)) }, - onClick = { onOpenSettings(); onDismiss() } - ) - } - AppBarMenu.Account -> { - SubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.relogin)) }, - onClick = { onRelogin(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.logout)) }, - onClick = { onLogout(); onDismiss() } - ) - } - AppBarMenu.Channel -> { - SubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.open_channel)) }, - onClick = { onOpenChannel(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_channel)) }, - onClick = { onRemoveChannel(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_channel)) }, - onClick = { onReportChannel(); onDismiss() } - ) - if (isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_channel)) }, - onClick = { onBlockChannel(); onDismiss() } - ) - } - } - AppBarMenu.Upload -> { - SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.take_picture)) }, - onClick = { onCaptureImage(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.record_video)) }, - onClick = { onCaptureVideo(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.choose_media)) }, - onClick = { onChooseMedia(); onDismiss() } - ) - } - AppBarMenu.More -> { - SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.reload_emotes)) }, - onClick = { onReloadEmotes(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.reconnect)) }, - onClick = { onReconnect(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.clear_chat)) }, - onClick = { onClearChat(); onDismiss() } - ) - } - null -> {} - } - } - } - } -} - @Composable private fun SubMenuHeader(title: String, onBack: () -> Unit) { DropdownMenuItem( - text = { + text = { Text( text = title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) + ) }, leadingIcon = { Icon( @@ -453,137 +269,4 @@ private fun SubMenuHeader(title: String, onBack: () -> Unit) { }, onClick = onBack ) -} - -@Composable -fun InlineOverflowMenu( - isLoggedIn: Boolean, - isStreamActive: Boolean = false, - hasStreamData: Boolean = false, - onDismiss: () -> Unit, - onLogin: () -> Unit, - onRelogin: () -> Unit, - onLogout: () -> Unit, - onManageChannels: () -> Unit, - onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, - onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, - onClearChat: () -> Unit, - onToggleStream: () -> Unit = {}, - onOpenSettings: () -> Unit, - initialMenu: AppBarMenu = AppBarMenu.Main, -) { - var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } - - 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 { - when (menu) { - AppBarMenu.Main -> { - if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login)) { onLogin(); onDismiss() } - } else { - InlineMenuItem(text = stringResource(R.string.account), hasSubMenu = true) { currentMenu = AppBarMenu.Account } - } - InlineMenuItem(text = stringResource(R.string.manage_channels)) { onManageChannels(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.channel), hasSubMenu = true) { currentMenu = AppBarMenu.Channel } - InlineMenuItem(text = stringResource(R.string.upload_media), hasSubMenu = true) { currentMenu = AppBarMenu.Upload } - InlineMenuItem(text = stringResource(R.string.more), hasSubMenu = true) { currentMenu = AppBarMenu.More } - InlineMenuItem(text = stringResource(R.string.settings)) { onOpenSettings(); onDismiss() } - } - AppBarMenu.Account -> { - InlineSubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.relogin)) { onRelogin(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.logout)) { onLogout(); onDismiss() } - } - AppBarMenu.Channel -> { - InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - if (hasStreamData || isStreamActive) { - InlineMenuItem(text = stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) { onToggleStream(); onDismiss() } - } - InlineMenuItem(text = stringResource(R.string.open_channel)) { onOpenChannel(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.remove_channel)) { onRemoveChannel(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.report_channel)) { onReportChannel(); onDismiss() } - if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel)) { onBlockChannel(); onDismiss() } - } - } - AppBarMenu.Upload -> { - InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.take_picture)) { onCaptureImage(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.record_video)) { onCaptureVideo(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.choose_media)) { onChooseMedia(); onDismiss() } - } - AppBarMenu.More -> { - InlineSubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.reload_emotes)) { onReloadEmotes(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.reconnect)) { onReconnect(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.clear_chat)) { onClearChat(); onDismiss() } - } - } - } - } -} - -@Composable -private fun InlineMenuItem(text: String, hasSubMenu: Boolean = false, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - if (hasSubMenu) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun InlineSubMenuHeader(title: String, onBack: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onBack) - .padding(horizontal = 12.dp, vertical = 12.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 - ) - } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index e4d51c8a9..543489c81 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -1,42 +1,37 @@ package com.flxrs.dankchat.main.compose -import androidx.activity.compose.PredictiveBackHandler +import android.content.ClipData import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -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.ExperimentalFoundationApi import androidx.compose.foundation.background 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -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.padding -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -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 @@ -45,48 +40,69 @@ 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.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +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.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatComposable +import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.message.compose.MessageOptionsState +import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel import com.flxrs.dankchat.chat.user.UserPopupStateParams +import com.flxrs.dankchat.chat.user.compose.UserPopupDialog import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.main.compose.FullScreenSheetState +import com.flxrs.dankchat.main.compose.InputSheetState import com.flxrs.dankchat.main.MainEvent -import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.main.compose.sheets.EmoteMenu +import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog +import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog +import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog +import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.main.compose.sheets.EmoteMenuSheet +import com.flxrs.dankchat.main.compose.sheets.MentionSheet +import com.flxrs.dankchat.main.compose.sheets.RepliesSheet import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore -import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.preferences.components.DankBackground import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun MainScreen( navController: NavController, @@ -106,7 +122,7 @@ fun MainScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current - val density = LocalDensity.current + val clipboardManager = LocalClipboard.current // Scoped ViewModels - each handles one concern val mainScreenViewModel: MainScreenViewModel = koinViewModel() val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() @@ -114,7 +130,6 @@ fun MainScreen( val channelPagerViewModel: ChannelPagerViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() - val streamViewModel: StreamViewModel = koinViewModel() val mentionViewModel: com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel = koinViewModel() val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() val developerSettingsDataStore: DeveloperSettingsDataStore = koinInject() @@ -122,88 +137,11 @@ fun MainScreen( val mainEventBus: MainEventBus = koinInject() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val keyboardController = LocalSoftwareKeyboardController.current - val configuration = androidx.compose.ui.platform.LocalConfiguration.current - val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + val uriHandler = LocalUriHandler.current val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = developerSettingsDataStore.current()) val isRepeatedSendEnabled = developerSettings.repeatedSending - var keyboardHeightPx by remember(isLandscape) { - val persisted = if (isLandscape) preferenceStore.keyboardHeightLandscape else preferenceStore.keyboardHeightPortrait - mutableIntStateOf(persisted) - } - - 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 = androidx.compose.runtime.rememberUpdatedState(currentImeHeight) - val isImeVisible = WindowInsets.isImeVisible - - LaunchedEffect(isLandscape, density) { - snapshotFlow { - (imeTarget.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) - } - .debounce(300) - .collect { height -> - val minHeight = with(density) { 100.dp.toPx() } - if (height > minHeight) { - keyboardHeightPx = height - if (isLandscape) { - preferenceStore.keyboardHeightLandscape = height - } else { - preferenceStore.keyboardHeightPortrait = height - } - } - } - } - - // 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 { mutableStateOf(0f) } - - // Stream state - val currentStream by streamViewModel.currentStreamedChannel.collectAsStateWithLifecycle() - val hasStreamData by chatInputViewModel.hasStreamData.collectAsStateWithLifecycle() - var streamHeightDp by remember { mutableStateOf(0.dp) } - LaunchedEffect(currentStream) { - if (currentStream == null) streamHeightDp = 0.dp - } - - - - // 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 (e: Exception) { - backProgress = 0f - } - } - var showAddChannelDialog by remember { mutableStateOf(false) } var showManageChannelsDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) } @@ -213,14 +151,6 @@ fun MainScreen( var userPopupParams by remember { mutableStateOf(null) } var messageOptionsParams by remember { mutableStateOf(null) } var emoteInfoEmotes by remember { mutableStateOf?>(null) } - var showRoomStateDialog by remember { mutableStateOf(false) } - var pendingUploadAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var isUploading by remember { mutableStateOf(false) } - var showLoginOutdatedDialog by remember { mutableStateOf(null) } - var showLoginExpiredDialog by remember { mutableStateOf(false) } - - val toolsSettingsDataStore: ToolsSettingsDataStore = koinInject() - val userStateRepository: UserStateRepository = koinInject() val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() @@ -229,48 +159,12 @@ fun MainScreen( mainEventBus.events.collect { event -> when (event) { is MainEvent.LogOutRequested -> showLogoutDialog = true - is MainEvent.UploadLoading -> isUploading = true - is MainEvent.UploadSuccess -> { - isUploading = false - val result = snackbarHostState.showSnackbar( - message = context.getString(R.string.snackbar_image_uploaded, event.url), - actionLabel = context.getString(R.string.snackbar_paste), - duration = SnackbarDuration.Long - ) - if (result == SnackbarResult.ActionPerformed) { - chatInputViewModel.insertText(event.url) - } - } - is MainEvent.UploadFailed -> { - isUploading = false - val message = event.errorMessage?.let { context.getString(R.string.snackbar_upload_failed_cause, it) } - ?: context.getString(R.string.snackbar_upload_failed) - snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) - } - is MainEvent.LoginValidated -> { - snackbarHostState.showSnackbar( - message = context.getString(R.string.snackbar_login, event.username), - duration = SnackbarDuration.Short - ) - } - is MainEvent.LoginOutdated -> { - showLoginOutdatedDialog = event.username - } - MainEvent.LoginTokenInvalid -> { - showLoginExpiredDialog = true - } - MainEvent.LoginValidationFailed -> { - snackbarHostState.showSnackbar( - message = context.getString(R.string.oauth_verify_failed), - duration = SnackbarDuration.Short - ) - } - else -> Unit + else -> Unit } } } - // Handle Login Result + // Handle Login Result (previously in handleLoginRequest) val navBackStackEntry = navController.currentBackStackEntry val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } LaunchedEffect(loginSuccess) { @@ -298,8 +192,7 @@ fun MainScreen( scope.launch { snackbarHostState.showSnackbar( message = state.message, - actionLabel = context.getString(R.string.snackbar_retry), - duration = SnackbarDuration.Long + actionLabel = context.getString(R.string.snackbar_retry) ) } } @@ -308,83 +201,228 @@ fun MainScreen( val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel - MainScreenDialogs( - channelState = ChannelDialogState( - showAddChannel = showAddChannelDialog, - showManageChannels = showManageChannelsDialog, - showRemoveChannel = showRemoveChannelDialog, - showBlockChannel = showBlockChannelDialog, - showClearChat = showClearChatDialog, - showRoomState = showRoomStateDialog, - activeChannel = activeChannel, - roomStateChannel = inputState.activeChannel, - onDismissAddChannel = { showAddChannelDialog = false }, - onDismissManageChannels = { showManageChannelsDialog = false }, - onDismissRemoveChannel = { showRemoveChannelDialog = false }, - onDismissBlockChannel = { showBlockChannelDialog = false }, - onDismissClearChat = { showClearChatDialog = false }, - onDismissRoomState = { showRoomStateDialog = false }, + if (showAddChannelDialog) { + AddChannelDialog( + onDismiss = { showAddChannelDialog = false }, onAddChannel = { channelManagementViewModel.addChannel(it) showAddChannelDialog = false + } + ) + } + + if (showManageChannelsDialog) { + val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() + ManageChannelsDialog( + channels = channels, + onApplyChanges = channelManagementViewModel::applyChanges, + onDismiss = { showManageChannelsDialog = false } + ) + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text(stringResource(R.string.confirm_logout_title)) }, + text = { Text(stringResource(R.string.confirm_logout_message)) }, + confirmButton = { + TextButton( + onClick = { + onLogout() + showLogoutDialog = false + } + ) { + Text(stringResource(R.string.confirm_logout_positive_button)) + } }, - ), - authState = AuthDialogState( - showLogout = showLogoutDialog, - showLoginOutdated = showLoginOutdatedDialog != null, - showLoginExpired = showLoginExpiredDialog, - onDismissLogout = { showLogoutDialog = false }, - onDismissLoginOutdated = { showLoginOutdatedDialog = null }, - onDismissLoginExpired = { showLoginExpiredDialog = false }, - onLogout = onLogout, - onLogin = onLogin, - ), - messageState = MessageInteractionState( - messageOptionsParams = messageOptionsParams, - emoteInfoEmotes = emoteInfoEmotes, - userPopupParams = userPopupParams, - inputSheetState = inputSheetState, - onDismissMessageOptions = { messageOptionsParams = null }, - onDismissEmoteInfo = { emoteInfoEmotes = null }, - onDismissUserPopup = { userPopupParams = null }, - onOpenChannel = onOpenChannel, - onReportChannel = onReportChannel, - onOpenUrl = onOpenUrl, - ), - snackbarHostState = snackbarHostState, - ) + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } - // External hosting upload disclaimer dialog - if (pendingUploadAction != null) { - val uploadHost = remember { - runCatching { - java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host - }.getOrElse { "" } - } + if (showRemoveChannelDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = { showRemoveChannelDialog = false }, + title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, + text = { Text(stringResource(R.string.confirm_channel_removal_message_named, activeChannel)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.removeChannel(activeChannel) + showRemoveChannelDialog = false + } + ) { + Text(stringResource(R.string.confirm_channel_removal_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showRemoveChannelDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showBlockChannelDialog && activeChannel != null) { AlertDialog( - onDismissRequest = { pendingUploadAction = null }, - title = { Text(stringResource(R.string.nuuls_upload_title)) }, - text = { Text(stringResource(R.string.external_upload_disclaimer, uploadHost)) }, + onDismissRequest = { showBlockChannelDialog = false }, + title = { Text(stringResource(R.string.confirm_channel_block_title)) }, + text = { Text(stringResource(R.string.confirm_channel_block_message_named, activeChannel)) }, confirmButton = { TextButton( onClick = { - preferenceStore.hasExternalHostingAcknowledged = true - val action = pendingUploadAction - pendingUploadAction = null - action?.invoke() + channelManagementViewModel.blockChannel(activeChannel) + showBlockChannelDialog = false + } + ) { + Text(stringResource(R.string.confirm_user_block_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = { showBlockChannelDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (showClearChatDialog && activeChannel != null) { + AlertDialog( + onDismissRequest = { showClearChatDialog = false }, + title = { Text(stringResource(R.string.clear_chat)) }, + text = { Text(stringResource(R.string.confirm_user_delete_message)) }, // Reuse message deletion text or find better one + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.clearChat(activeChannel) + showClearChatDialog = false } ) { Text(stringResource(R.string.dialog_ok)) } }, dismissButton = { - TextButton(onClick = { pendingUploadAction = null }) { + TextButton(onClick = { showClearChatDialog = false }) { Text(stringResource(R.string.dialog_cancel)) } } ) } + messageOptionsParams?.let { params -> + val viewModel: MessageOptionsComposeViewModel = koinViewModel( + key = params.messageId, + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } + ) + val state by viewModel.state.collectAsStateWithLifecycle() + (state as? MessageOptionsState.Found)?.let { s -> + MessageOptionsDialog( + messageId = s.messageId, + channel = params.channel?.value, + fullMessage = params.fullMessage, + canModerate = s.canModerate, + canReply = s.canReply, + canCopy = params.canCopy, + hasReplyThread = s.hasReplyThread, + onReply = { + chatInputViewModel.setReplying(true, s.messageId, s.replyName) + }, + onViewThread = { + sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) + }, + onCopy = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onMoreActions = { + sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) + }, + onDelete = viewModel::deleteMessage, + onTimeout = viewModel::timeoutUser, + onBan = viewModel::banUser, + onUnban = viewModel::unbanUser, + onDismiss = { messageOptionsParams = null } + ) + } + } + + emoteInfoEmotes?.let { emotes -> + val viewModel: EmoteInfoComposeViewModel = koinViewModel( + key = emotes.joinToString { it.id }, + parameters = { parametersOf(emotes) } + ) + EmoteInfoDialog( + items = viewModel.items, + onUseEmote = { chatInputViewModel.insertText("$it ") }, + onCopyEmote = { /* TODO: copy to clipboard */ }, + onOpenLink = { onOpenUrl(it) }, + onDismiss = { emoteInfoEmotes = null } + ) + } + + userPopupParams?.let { params -> + val viewModel: UserPopupComposeViewModel = 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) }, + onBlockUser = viewModel::blockUser, + onUnblockUser = viewModel::unblockUser, + onDismiss = { userPopupParams = null }, + onMention = { name, _ -> + chatInputViewModel.insertText("@$name ") + }, + onWhisper = { name -> + chatInputViewModel.updateInputText("/w $name ") + }, + onOpenChannel = { _ -> onOpenChannel() }, + onReport = { _ -> + onReportChannel() + } + ) + } + + if (inputSheetState is InputSheetState.EmoteMenu) { + EmoteMenuSheet( + onDismiss = sheetNavigationViewModel::closeInputSheet, + onEmoteClick = { code, _ -> + chatInputViewModel.insertText("$code ") + sheetNavigationViewModel.closeInputSheet() + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) + } + + if (inputSheetState is InputSheetState.MoreActions) { + val state = inputSheetState as InputSheetState.MoreActions + com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( + messageId = state.messageId, + fullMessage = state.fullMessage, + onCopyFullMessage = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onCopyMessageId = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_id_copied)) + } + }, + onDismiss = sheetNavigationViewModel::closeInputSheet, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) + } + val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() @@ -392,29 +430,28 @@ fun MainScreen( val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() + val inputState by chatInputViewModel.uiState(sheetNavigationViewModel.fullScreenSheetState, mentionViewModel.currentTab).collectAsStateWithLifecycle() val composePagerState = rememberPagerState( initialPage = pagerState.currentPage, pageCount = { pagerState.channels.size } ) + val density = LocalDensity.current var inputHeightPx by remember { mutableIntStateOf(0) } var inputTopY by remember { mutableStateOf(0f) } var containerHeight by remember { mutableIntStateOf(0) } val inputHeightDp = with(density) { inputHeightPx.toDp() } val sheetBottomPadding = with(density) { (containerHeight - inputTopY).toDp() } - // Clear focus when keyboard fully reaches the bottom, but not when - // switching to the emote menu. Prevents keyboard from reopening when - // returning from background. + // Track keyboard visibility - clear focus only when keyboard is fully closed val focusManager = LocalFocusManager.current - LaunchedEffect(Unit) { - snapshotFlow { imeHeightState.value == 0 && !inputState.isEmoteMenuOpen } - .distinctUntilChanged() - .collect { shouldClearFocus -> - if (shouldClearFocus) { - focusManager.clearFocus() - } - } + val imeAnimationTarget = WindowInsets.imeAnimationTarget + val isKeyboardAtBottom = imeAnimationTarget.getBottom(density) == 0 + + LaunchedEffect(isKeyboardAtBottom) { + if (isKeyboardAtBottom) { + focusManager.clearFocus() + } } // Sync Compose pager with ViewModel state @@ -427,331 +464,300 @@ fun MainScreen( } } - // Update ViewModel when user swipes (use settledPage to avoid clearing - // unread/mention indicators for pages scrolled through during programmatic jumps) - LaunchedEffect(composePagerState.settledPage) { - if (composePagerState.settledPage != pagerState.currentPage) { - channelPagerViewModel.onPageChanged(composePagerState.settledPage) + // Update ViewModel when user swipes + LaunchedEffect(composePagerState.currentPage) { + if (composePagerState.currentPage != pagerState.currentPage) { + channelPagerViewModel.onPageChanged(composePagerState.currentPage) } } + val isKeyboardVisible = WindowInsets.isImeVisible || imeAnimationTarget.getBottom(density) > 0 + + val systemBarsPaddingModifier = if (isFullscreen) Modifier else Modifier.statusBarsPadding() + Box(modifier = Modifier .fillMaxSize() .onGloballyPositioned { containerHeight = it.size.height } ) { - // 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 totalMenuHeight = targetMenuHeight + navBarHeightDp - - // Ignore IME height when a dialog with its own text field is open, - // otherwise the scaffold shifts up behind the dialog unnecessarily. - val hasDialogWithInput = showAddChannelDialog || showRoomStateDialog || showManageChannelsDialog - 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) - Scaffold( modifier = modifier .fillMaxSize() - .padding(bottom = scaffoldBottomPadding), - contentWindowInsets = WindowInsets(0), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - bottomBar = { - Column(modifier = Modifier.fillMaxWidth()) { - if (showInputState) { - ChatInputLayout( - textFieldState = chatInputViewModel.textFieldState, - inputState = inputState.inputState, - enabled = inputState.enabled, - canSend = inputState.canSend, - showReplyOverlay = inputState.showReplyOverlay, - replyName = inputState.replyName, - isEmoteMenuOpen = inputState.isEmoteMenuOpen, - helperText = inputState.helperText, - isUploading = isUploading, - isFullscreen = isFullscreen, - isModerator = userStateRepository.isModeratorInChannel(inputState.activeChannel), - isStreamActive = currentStream != null, - hasStreamData = hasStreamData, - onSend = chatInputViewModel::sendMessage, - onLastMessageClick = chatInputViewModel::getLastMessage, - onEmoteClick = { - if (!inputState.isEmoteMenuOpen) { - keyboardController?.hide() - chatInputViewModel.setEmoteMenuOpen(true) - } else { - keyboardController?.show() - } - }, - onReplyDismiss = { - chatInputViewModel.setReplying(false) - }, - onToggleFullscreen = mainScreenViewModel::toggleFullscreen, - onToggleInput = mainScreenViewModel::toggleInput, - onToggleStream = { - activeChannel?.let { streamViewModel.toggleStream(it) } - }, - onChangeRoomState = { showRoomStateDialog = true }, - modifier = Modifier.onGloballyPositioned { coordinates -> - inputHeightPx = coordinates.size.height - inputTopY = coordinates.positionInRoot().y - } - ) - } + .then(systemBarsPaddingModifier) + .imePadding(), + topBar = { + if (tabState.tabs.isEmpty()) { + return@Scaffold + } - // Sticky helper text + nav bar spacer when input is hidden - if (!showInputState) { - val helperText = inputState.helperText - if (!helperText.isNullOrEmpty()) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = helperText, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - modifier = Modifier - .navigationBarsPadding() - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Start - ) - } - } - } - } + AnimatedVisibility( + visible = showAppBar && !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + MainAppBar( + isLoggedIn = isLoggedIn, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, + onAddChannel = { showAddChannelDialog = true }, + onOpenMentions = { sheetNavigationViewModel.openMentions() }, + onOpenWhispers = { sheetNavigationViewModel.openWhispers() }, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = { showLogoutDialog = true }, + onManageChannels = { showManageChannelsDialog = true }, + onOpenChannel = onOpenChannel, + onRemoveChannel = { showRemoveChannelDialog = true }, + onReportChannel = onReportChannel, + onBlockChannel = { showBlockChannelDialog = true }, + onReloadEmotes = { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + onReloadEmotes() + }, + onReconnect = { + channelManagementViewModel.reconnect() + onReconnect() + }, + onClearChat = { showClearChatDialog = true }, + onOpenSettings = onNavigateToSettings + ) } - ) { paddingValues -> - // Main content - Box(modifier = Modifier.fillMaxSize()) { - val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() - DankBackground(visible = showFullScreenLoading) - if (showFullScreenLoading) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + AnimatedVisibility( + visible = showInputState && !isFullscreen, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + Box(modifier = Modifier.fillMaxWidth()) { + ChatInputLayout( + textFieldState = chatInputViewModel.textFieldState, + inputState = inputState.inputState, + enabled = inputState.enabled, + canSend = inputState.canSend, + showReplyOverlay = inputState.showReplyOverlay, + replyName = inputState.replyName, + onSend = chatInputViewModel::sendMessage, + onLastMessageClick = chatInputViewModel::getLastMessage, + onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, + onReplyDismiss = { + chatInputViewModel.setReplying(false) + }, + modifier = Modifier.onGloballyPositioned { coordinates -> + inputHeightPx = coordinates.size.height + inputTopY = coordinates.positionInRoot().y + } ) - return@Scaffold } - if (tabState.tabs.isEmpty() && !tabState.loading) { - EmptyStateContent( - isLoggedIn = isLoggedIn, - onAddChannel = { showAddChannelDialog = true }, - onLogin = onLogin, - onToggleAppBar = mainScreenViewModel::toggleAppBar, - modifier = Modifier.padding(paddingValues) - ) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + } + } + ) { paddingValues -> + // Main content of the chat (tabs, pager, empty state) + Box(modifier = Modifier.fillMaxSize()) { // This box gets the Scaffold's content padding + val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() + DankBackground(visible = showFullScreenLoading) + if (showFullScreenLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + ) + return@Scaffold + } + if (tabState.tabs.isEmpty() && !tabState.loading) { + EmptyStateContent( + isLoggedIn = isLoggedIn, + onAddChannel = { showAddChannelDialog = true }, + onLogin = onLogin, + onToggleAppBar = mainScreenViewModel::toggleAppBar, + onToggleFullscreen = mainScreenViewModel::toggleFullscreen, + onToggleInput = mainScreenViewModel::toggleInput, + modifier = Modifier.padding(paddingValues) + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (tabState.loading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + AnimatedVisibility( + visible = !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() ) { - HorizontalPager( - state = composePagerState, - modifier = Modifier.fillMaxSize(), - 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, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - }, - onReplyClick = { replyMessageId, replyName -> - sheetNavigationViewModel.openReplies(replyMessageId, replyName) - }, - showInput = showInputState, - isFullscreen = isFullscreen, - hasHelperText = !inputState.helperText.isNullOrEmpty(), - onRecover = { - if (isFullscreen) mainScreenViewModel.toggleFullscreen() - if (!showInputState) mainScreenViewModel.toggleInput() - }, - contentPadding = PaddingValues( - top = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamHeightDp) + 56.dp - ), - onScrollDirectionChanged = { } - ) + ChannelTabRow( + tabs = tabState.tabs, + selectedIndex = tabState.selectedIndex, + onTabSelected = { + channelTabViewModel.selectTab(it) + scope.launch { + composePagerState.animateScrollToPage(it) + } } + ) + } + HorizontalPager( + state = composePagerState, + modifier = Modifier.fillMaxSize(), + 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, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + }, + onReplyClick = { replyMessageId, replyName -> + sheetNavigationViewModel.openReplies(replyMessageId, replyName) + } + ) } } } } } + } - // Stream View layer - currentStream?.let { channel -> - StreamView( - channel = channel, - streamViewModel = streamViewModel, - onClose = { - focusManager.clearFocus() - streamViewModel.closeStream() - }, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - streamHeightDp = with(density) { coordinates.size.height.toDp() } - } - ) - } - - // Status bar scrim + // Fullscreen Overlay Sheets + androidx.compose.animation.AnimatedVisibility( + visible = fullScreenSheetState !is FullScreenSheetState.Closed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier + .fillMaxSize() + .padding(bottom = sheetBottomPadding) + ) { Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .background(if (currentStream != null) Color.Black else MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) - ) - - // Floating Toolbars - collapsible tabs (expand on swipe) + actions - FloatingToolbar( - tabState = tabState, - composePagerState = composePagerState, - showAppBar = showAppBar, - isFullscreen = isFullscreen, - isLoggedIn = isLoggedIn, - currentStream = currentStream, - hasStreamData = hasStreamData, - streamHeightDp = streamHeightDp, - totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onTabSelected = { index -> - channelTabViewModel.selectTab(index) - scope.launch { composePagerState.scrollToPage(index) } - }, - onTabLongClick = { index -> - channelTabViewModel.selectTab(index) - scope.launch { composePagerState.scrollToPage(index) } - }, - onAddChannel = { showAddChannelDialog = true }, - onOpenMentions = { sheetNavigationViewModel.openMentions() }, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = { showLogoutDialog = true }, - onManageChannels = { showManageChannelsDialog = true }, - onOpenChannel = onOpenChannel, - onRemoveChannel = { showRemoveChannelDialog = true }, - onReportChannel = onReportChannel, - onBlockChannel = { showBlockChannelDialog = true }, - onCaptureImage = { - if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else pendingUploadAction = onCaptureImage - }, - onCaptureVideo = { - if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else pendingUploadAction = onCaptureVideo - }, - onChooseMedia = { - if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else pendingUploadAction = onChooseMedia - }, - onReloadEmotes = { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() - }, - onReconnect = { - channelManagementViewModel.reconnect() - onReconnect() - }, - onClearChat = { showClearChatDialog = true }, - onToggleStream = { - activeChannel?.let { streamViewModel.toggleStream(it) } - }, - onOpenSettings = onNavigateToSettings, - modifier = Modifier.align(Alignment.TopCenter), - ) - - // Emote Menu Layer - slides up/down independently of keyboard - // Fast tween to match system keyboard animation speed - AnimatedVisibility( - visible = inputState.isEmoteMenuOpen, - enter = slideInVertically(animationSpec = tween(durationMillis = 140), initialOffsetY = { it }), - exit = slideOutVertically(animationSpec = tween(durationMillis = 140), targetOffsetY = { it }), - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier.fillMaxSize() ) { - 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 = { code, _ -> - chatInputViewModel.insertText("$code ") - }, - modifier = Modifier.fillMaxSize() - ) + when (val state = fullScreenSheetState) { + is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Mention -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = false, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + } + ) + } + is FullScreenSheetState.Whisper -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = true, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + } + ) + } + + is FullScreenSheetState.Replies -> { + RepliesSheet( + rootMessageId = state.replyMessageId, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = { userId, userName, displayName, channel, badges, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + } + ) + } } } + } - // Fullscreen Overlay Sheets - FullScreenSheetOverlay( - sheetState = fullScreenSheetState, - isLoggedIn = isLoggedIn, - mentionViewModel = mentionViewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onDismissReplies = { - sheetNavigationViewModel.closeFullScreenSheet() - chatInputViewModel.setReplying(false) - }, - onUserClick = { userPopupParams = it }, - onMessageLongClick = { messageOptionsParams = it }, - onEmoteClick = { emoteInfoEmotes = it }, - modifier = Modifier.padding(bottom = sheetBottomPadding), + if (showInputState && !isFullscreen && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp) ) - - if (showInputState && isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp) - ) - } } } - +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt deleted file mode 100644 index c04cdf77e..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ /dev/null @@ -1,374 +0,0 @@ -package com.flxrs.dankchat.main.compose - -import android.content.ClipData -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -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.lifecycle.compose.collectAsStateWithLifecycle -import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.message.compose.MessageOptionsState -import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel -import com.flxrs.dankchat.chat.user.UserPopupStateParams -import com.flxrs.dankchat.chat.user.compose.UserPopupDialog -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.channel.ChannelRepository -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog -import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog -import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog -import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog -import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog -import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf - -@Stable -data class ChannelDialogState( - val showAddChannel: Boolean, - val showManageChannels: Boolean, - val showRemoveChannel: Boolean, - val showBlockChannel: Boolean, - val showClearChat: Boolean, - val showRoomState: Boolean, - val activeChannel: UserName?, - val roomStateChannel: UserName?, - val onDismissAddChannel: () -> Unit, - val onDismissManageChannels: () -> Unit, - val onDismissRemoveChannel: () -> Unit, - val onDismissBlockChannel: () -> Unit, - val onDismissClearChat: () -> Unit, - val onDismissRoomState: () -> Unit, - val onAddChannel: (UserName) -> Unit, -) - -@Stable -data class AuthDialogState( - val showLogout: Boolean, - val showLoginOutdated: Boolean, - val showLoginExpired: Boolean, - val onDismissLogout: () -> Unit, - val onDismissLoginOutdated: () -> Unit, - val onDismissLoginExpired: () -> Unit, - val onLogout: () -> Unit, - val onLogin: () -> Unit, -) - -@Stable -data class MessageInteractionState( - val messageOptionsParams: MessageOptionsParams?, - val emoteInfoEmotes: List?, - val userPopupParams: UserPopupStateParams?, - val inputSheetState: InputSheetState, - val onDismissMessageOptions: () -> Unit, - val onDismissEmoteInfo: () -> Unit, - val onDismissUserPopup: () -> Unit, - val onOpenChannel: () -> Unit, - val onReportChannel: () -> Unit, - val onOpenUrl: (String) -> Unit, -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainScreenDialogs( - channelState: ChannelDialogState, - authState: AuthDialogState, - messageState: MessageInteractionState, - snackbarHostState: SnackbarHostState, -) { - val context = LocalContext.current - val clipboardManager = LocalClipboard.current - val scope = rememberCoroutineScope() - - val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() - val chatInputViewModel: ChatInputViewModel = koinViewModel() - val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() - val channelRepository: ChannelRepository = koinInject() - - // region Channel dialogs - - if (channelState.showAddChannel) { - AddChannelDialog( - onDismiss = channelState.onDismissAddChannel, - onAddChannel = channelState.onAddChannel - ) - } - - if (channelState.showManageChannels) { - val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() - ManageChannelsDialog( - channels = channels, - onApplyChanges = channelManagementViewModel::applyChanges, - onDismiss = channelState.onDismissManageChannels - ) - } - - if (channelState.showRoomState && channelState.roomStateChannel != null) { - RoomStateDialog( - roomState = channelRepository.getRoomState(channelState.roomStateChannel), - onSendCommand = { command -> - chatInputViewModel.trySendMessageOrCommand(command) - }, - onDismiss = channelState.onDismissRoomState - ) - } - - if (channelState.showRemoveChannel && channelState.activeChannel != null) { - AlertDialog( - onDismissRequest = channelState.onDismissRemoveChannel, - title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, - text = { Text(stringResource(R.string.confirm_channel_removal_message_named, channelState.activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.removeChannel(channelState.activeChannel) - channelState.onDismissRemoveChannel() - } - ) { - Text(stringResource(R.string.confirm_channel_removal_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = channelState.onDismissRemoveChannel) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (channelState.showBlockChannel && channelState.activeChannel != null) { - AlertDialog( - onDismissRequest = channelState.onDismissBlockChannel, - title = { Text(stringResource(R.string.confirm_channel_block_title)) }, - text = { Text(stringResource(R.string.confirm_channel_block_message_named, channelState.activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.blockChannel(channelState.activeChannel) - channelState.onDismissBlockChannel() - } - ) { - Text(stringResource(R.string.confirm_user_block_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = channelState.onDismissBlockChannel) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (channelState.showClearChat && channelState.activeChannel != null) { - AlertDialog( - onDismissRequest = channelState.onDismissClearChat, - title = { Text(stringResource(R.string.clear_chat)) }, - text = { Text(stringResource(R.string.confirm_user_delete_message)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.clearChat(channelState.activeChannel) - channelState.onDismissClearChat() - } - ) { - Text(stringResource(R.string.dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = channelState.onDismissClearChat) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - // endregion - - // region Auth dialogs - - if (authState.showLogout) { - AlertDialog( - onDismissRequest = authState.onDismissLogout, - title = { Text(stringResource(R.string.confirm_logout_title)) }, - text = { Text(stringResource(R.string.confirm_logout_message)) }, - confirmButton = { - TextButton( - onClick = { - authState.onLogout() - authState.onDismissLogout() - } - ) { - Text(stringResource(R.string.confirm_logout_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = authState.onDismissLogout) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (authState.showLoginOutdated) { - AlertDialog( - onDismissRequest = authState.onDismissLoginOutdated, - title = { Text(stringResource(R.string.login_outdated_title)) }, - text = { Text(stringResource(R.string.login_outdated_message)) }, - confirmButton = { - TextButton(onClick = { - authState.onDismissLoginOutdated() - authState.onLogin() - }) { - Text(stringResource(R.string.oauth_expired_login_again)) - } - }, - dismissButton = { - TextButton(onClick = authState.onDismissLoginOutdated) { - Text(stringResource(R.string.dialog_dismiss)) - } - } - ) - } - - if (authState.showLoginExpired) { - AlertDialog( - onDismissRequest = authState.onDismissLoginExpired, - title = { Text(stringResource(R.string.oauth_expired_title)) }, - text = { Text(stringResource(R.string.oauth_expired_message)) }, - confirmButton = { - TextButton(onClick = { - authState.onDismissLoginExpired() - authState.onLogin() - }) { - Text(stringResource(R.string.oauth_expired_login_again)) - } - }, - dismissButton = { - TextButton(onClick = authState.onDismissLoginExpired) { - Text(stringResource(R.string.dialog_dismiss)) - } - } - ) - } - - // endregion - - // region Message interactions - - messageState.messageOptionsParams?.let { params -> - val viewModel: MessageOptionsComposeViewModel = koinViewModel( - key = params.messageId, - parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } - ) - val state by viewModel.state.collectAsStateWithLifecycle() - (state as? MessageOptionsState.Found)?.let { s -> - MessageOptionsDialog( - messageId = s.messageId, - channel = params.channel?.value, - fullMessage = params.fullMessage, - canModerate = s.canModerate, - canReply = s.canReply, - canCopy = params.canCopy, - hasReplyThread = s.hasReplyThread, - onReply = { - chatInputViewModel.setReplying(true, s.messageId, s.replyName) - }, - onViewThread = { - sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) - }, - onCopy = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) - } - }, - onMoreActions = { - sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) - }, - onDelete = viewModel::deleteMessage, - onTimeout = viewModel::timeoutUser, - onBan = viewModel::banUser, - onUnban = viewModel::unbanUser, - onDismiss = messageState.onDismissMessageOptions - ) - } - } - - messageState.emoteInfoEmotes?.let { emotes -> - val viewModel: EmoteInfoComposeViewModel = koinViewModel( - key = emotes.joinToString { it.id }, - parameters = { parametersOf(emotes) } - ) - EmoteInfoDialog( - items = viewModel.items, - onUseEmote = { chatInputViewModel.insertText("$it ") }, - onCopyEmote = { /* TODO: copy to clipboard */ }, - onOpenLink = { messageState.onOpenUrl(it) }, - onDismiss = messageState.onDismissEmoteInfo - ) - } - - messageState.userPopupParams?.let { params -> - val viewModel: UserPopupComposeViewModel = 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) }, - onBlockUser = viewModel::blockUser, - onUnblockUser = viewModel::unblockUser, - onDismiss = messageState.onDismissUserPopup, - onMention = { name, _ -> - chatInputViewModel.insertText("@$name ") - }, - onWhisper = { name -> - chatInputViewModel.updateInputText("/w $name ") - }, - onOpenChannel = { _ -> messageState.onOpenChannel() }, - onReport = { _ -> - messageState.onReportChannel() - } - ) - } - - val inputSheet = messageState.inputSheetState - if (inputSheet is InputSheetState.MoreActions) { - com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( - messageId = inputSheet.messageId, - fullMessage = inputSheet.fullMessage, - onCopyFullMessage = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) - } - }, - onCopyMessageId = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_id_copied)) - } - }, - onDismiss = sheetNavigationViewModel::closeInputSheet, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) - } - - // endregion -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt deleted file mode 100644 index ec6fe8d47..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.flxrs.dankchat.main.compose - -import android.view.ViewGroup -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -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.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -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.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.UserName - -@Composable -fun StreamView( - channel: UserName, - streamViewModel: StreamViewModel, - onClose: () -> Unit, - modifier: Modifier = Modifier, -) { - // 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) } - val webView = remember { - streamViewModel.getOrCreateWebView().also { wv -> - wv.setBackgroundColor(android.graphics.Color.TRANSPARENT) - wv.webViewClient = StreamComposeWebViewClient( - onPageFinished = { isPageLoaded = true } - ) - } - } - - // 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.currentStreamedChannel.value == null) { - streamViewModel.destroyWebView(webView) - } - } - } - - Box( - modifier = modifier - .statusBarsPadding() - .fillMaxWidth() - .background(Color.Black) - ) { - 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 - } - webView - }, - update = { _ -> - // For subsequent opens: load URL while attached - streamViewModel.setStream(channel, webView) - }, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - ) - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - ) - } - - IconButton( - onClick = onClose, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - .size(36.dp) - .background( - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f), - shape = CircleShape - ) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.dialog_dismiss), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } -} - -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/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt deleted file mode 100644 index c1e3b5e0f..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.flxrs.dankchat.main.compose - -import android.annotation.SuppressLint -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.stream.StreamDataRepository -import com.flxrs.dankchat.main.stream.StreamWebView -import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import org.koin.android.annotation.KoinViewModel - -@KoinViewModel -class StreamViewModel( - application: Application, - private val streamDataRepository: StreamDataRepository, - private val streamsSettingsDataStore: StreamsSettingsDataStore, -) : AndroidViewModel(application) { - - private val _currentStreamedChannel = MutableStateFlow(null) - val currentStreamedChannel: StateFlow = _currentStreamedChannel.asStateFlow() - - 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 } - } - - fun closeStream() { - _currentStreamedChannel.value = null - } - - override fun onCleared() { - cachedWebView?.destroy() - cachedWebView = null - lastStreamedChannel = null - super.onCleared() - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt deleted file mode 100644 index 6b551f188..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.flxrs.dankchat.main.compose.dialogs - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.ModalBottomSheet -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.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.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.twitch.message.RoomState - -private enum class ParameterDialogType { - SLOW_MODE, - FOLLOWER_MODE -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun RoomStateDialog( - roomState: RoomState?, - onSendCommand: (String) -> Unit, - onDismiss: () -> Unit, -) { - var parameterDialog by remember { mutableStateOf(null) } - - parameterDialog?.let { type -> - val (title, hint, defaultValue, commandPrefix) = when (type) { - ParameterDialogType.SLOW_MODE -> listOf( - R.string.room_state_slow_mode, - R.string.seconds, - "30", - "/slow" - ) - ParameterDialogType.FOLLOWER_MODE -> listOf( - R.string.room_state_follower_only, - R.string.minutes, - "10", - "/followers" - ) - } - - var inputValue by remember { mutableStateOf(defaultValue as String) } - - AlertDialog( - onDismissRequest = { parameterDialog = null }, - title = { Text(stringResource(title as Int)) }, - text = { - OutlinedTextField( - value = inputValue, - onValueChange = { inputValue = it }, - label = { Text(stringResource(hint as Int)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton(onClick = { - onSendCommand("$commandPrefix $inputValue") - parameterDialog = null - onDismiss() - }) { - Text(stringResource(R.string.dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = { parameterDialog = null }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), - 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 = { - if (isSlowMode) { - onSendCommand("/slowoff") - onDismiss() - } else { - parameterDialog = ParameterDialogType.SLOW_MODE - } - }, - label = { - val label = stringResource(R.string.room_state_slow_mode) - Text(if (isSlowMode && slowModeWaitTime != null) "$label (${slowModeWaitTime}s)" 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 = { - if (isFollowerOnly) { - onSendCommand("/followersoff") - onDismiss() - } else { - parameterDialog = ParameterDialogType.FOLLOWER_MODE - } - }, - label = { - val label = stringResource(R.string.room_state_follower_only) - Text(if (isFollowerOnly && followerDuration != null && followerDuration > 0) "$label (${followerDuration}m)" else label) - }, - leadingIcon = if (isFollowerOnly) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else null, - ) - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt deleted file mode 100644 index 7d9d5718c..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.flxrs.dankchat.main.compose.sheets - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -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.ui.platform.LocalDensity -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Tab -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.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.Alignment -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.emotemenu.EmoteItem -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab -import com.flxrs.dankchat.main.compose.EmoteMenuViewModel -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel - -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.Surface -import com.flxrs.dankchat.preferences.components.DankBackground - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EmoteMenu( - onEmoteClick: (String, String) -> Unit, - viewModel: EmoteMenuViewModel = koinViewModel(), - modifier: Modifier = Modifier -) { - val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState( - initialPage = 0, - pageCount = { tabItems.size } - ) - - 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) - } - ) - } - ) - } - } - - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - beyondViewportPageCount = 1 - ) { page -> - val tab = tabItems[page] - 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) // Offset below logo - ) - } - } else { - val navBarBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current) - val navBarBottomDp = with(LocalDensity.current) { navBarBottom.toDp() } - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 48.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.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/preferences/DankChatPreferenceStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt index ab4d719b5..5172df84d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -81,14 +81,6 @@ 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) } @@ -226,8 +218,6 @@ class DankChatPreferenceStore( 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" diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c554b23af..e1af229f6 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -414,7 +414,4 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ VIP Gründer Abonnent - Wähle benutzerdefinierte Highlight Farbe - Standard - Farbe wählen diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b2b118f8e..bc5064543 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -407,7 +407,4 @@ VIP Founder Subscriber - Pick custom highlight color - Default - Choose Color diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index aad74fc7f..406307a3a 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -322,12 +322,10 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Usuarios Lista Negra de Usuarios Twitch - Emblemas Deshacer Elemento eliminado Usuario %1$s desbloqueado Error al desbloquear el usuario %1$s - Emblema Error al bloquear el usuario %1$s Tu usuario Suscripciones y Eventos @@ -340,7 +338,6 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Crea notificaciones y destaca mensajes basados en ciertos patrones. Crea notificaciones y destaca los mensajes de ciertos usuarios. Desactiva las notificaciones y los destacados de ciertos usuarios (ej. bots). - Crea notificaciones y destaca los mensajes de los usuarios en función de los emblemas. Ignorar mensajes basados en ciertos patrones. Ignorar mensajes de ciertos usuarios. Gestiona los usuarios bloqueados de Twitch. @@ -405,16 +402,4 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Mostrar categoría del stream Muestra también la categoría del stream Alternar entrada - Streamer - Administrador - Staff - Moderador - Moderador principal - Verificado - VIP - Fundador - Suscriptor - Elegir color de resaltado personalizado - Predeterminado - Elegir color diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 098521678..49c94ae01 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -211,7 +211,6 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Lukee vain viestin ääneen Lukee käyttäjän ja viestin Viestin muoto - Ohittaa URL-osoitteet TTS:ssä TTS Ruudulliset viivat Erota kukin rivi erillaisella taustan kirkkaudella diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 1c383a6bf..645b32330 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -320,12 +320,10 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Użytkownicy Użytkownicy na Czarnej liście Twitch - Odznaki Cofnij Element został usunięty Odblokowano użytkownika %1$s Nie udało się odblokować użytkownika %1$s - Odznaka Nie udało się zablokować użytkownika %1$s Twoja nazwa użytkownika Subskrypcje i Wydarzenia @@ -338,7 +336,6 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Tworzy powiadomienia i wyróżnia wiadomości na podstawie określonych wzorów. Tworzy powiadomienia i wyróżnia wiadomości od określonych użytkowników. Wyłącz powiadomienia i wyróżnienia od określonych użytkowników (np. botów) - Utwórz powiadomienia i wyróżnienia wiadomości od użytkowników na podstawie odznak. Ignoruj wiadomości na podstawie określonych wzorów. Ignoruj wiadomości od określonych użytkowników. Zarządzaj zablokowanymi użytkownikami. @@ -386,7 +383,6 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pomniejsz Powiększ Wróć - Wspólny Czat Na żywo z %1$d widzem przez %2$s Na żywo z %1$d widzami przez %2$s @@ -399,26 +395,4 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %d miesięcy %d miesięcy - Licencje Open Source - - Na żywo z %1$d widzem w %2$s od %3$s - Na żywo z %1$d widzami w %2$s od %3$s - Na żywo z %1$d widzami w %2$s od %3$s - Na żywo z %1$d widzami w %2$s od %3$s - - Pokaż kategorię transmisji - Wyświetlaj również kategorię transmisji - Przełącz pole wprowadzania - Nadawca - Admin - Personel - Moderator - Główny Moderator - Zweryfikowane - VIP - Założyciel - Subskrybent - Wybierz niestandardowy kolor podświetlenia - Domyślny - Wybierz Kolor diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e91d07899..04b5ea691 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -322,12 +322,10 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Kullanıcılar Kara Listeli Kullanıcılar Twitch - Rozetler Geri al Öge kaldırıldı %1$s kullanıcısının engeli kaldırıldı %1$s kullanıcısının engeli kaldırılamadı - Rozet %1$s kullanıcısı engellenemedi Kullanıcı adınız Abonelikler ile Etkinlikler @@ -340,7 +338,6 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Belli şablonlara göre bildirimler oluşturup mesajları öne çıkarır. Belli kullanıcılardan bildirimler oluşturup mesajlar öne çıkarır. Belli kullanıcılardan (örneğin botlardan) bildirimler ile öne çıkarmaları etkisizleştirir. - Rozetlere göre kullanıcıların mesajlarından bildirimler ve vurgular oluştur. Belli şablonlara göre mesajları yoksayar. Belli kullanıcılardan gelen mesajları yok say. Engellenen Twitch kullanıcılarını yönet. @@ -405,16 +402,4 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Yayın kategorisini göster Yayın kategorisini de göster Girişi Değiştir - Yayıncı - Yönetici - Ekip - Moderatör - Baş moderatör - Doğrulandı - VIP - Kurucu - Abone - Özel vurgu rengi seç - Varsayılan - Renk Seç diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d68c7e792..596550ca3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,8 +43,6 @@ FeelsDankMan DankChat running in the background Open the emote menu - Close the emote menu - No recent emotes Emotes Login to Twitch.tv Start chatting @@ -128,17 +126,6 @@ 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 - Fullscreen - Exit fullscreen - Hide input -Room state - Emote only - Subscriber only - Slow mode - Unique chat (R9K) - Follower only Account Login again Logout diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 470bf05ec..b94dcd0af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,39 +1,39 @@ [versions] -kotlin = "2.3.10" +kotlin = "2.3.0" coroutines = "1.10.2" -serialization = "1.10.0" +serialization = "1.9.0" datetime = "0.7.1-0.6.x-compat" immutable = "0.4.0" -ktor = "3.4.0" +ktor = "3.3.3" coil = "3.3.0" okhttp = "5.3.2" -ksp = "2.3.5" +ksp = "2.3.4" koin = "4.1.1" koin-annotations = "2.3.1" about-libraries = "13.2.1" androidGradlePlugin = "8.13.2" androidDesugarLibs = "2.1.5" -androidxActivity = "1.12.4" +androidxActivity = "1.12.2" androidxBrowser = "1.9.0" androidxConstraintLayout = "2.2.1" androidxCore = "1.17.0" androidxEmoji2 = "1.6.0" androidxExif = "1.4.2" androidxFragment = "1.8.9" -androidxTransition = "1.7.0" +androidxTransition = "1.6.0" androidxLifecycle = "2.10.0" androidxMedia = "1.7.1" -androidxNavigation = "2.9.7" +androidxNavigation = "2.9.5" androidxRecyclerview = "1.4.0" androidxViewpager2 = "1.1.0" androidxRoom = "2.8.4" androidxWebkit = "1.15.0" androidxDataStore = "1.2.0" -compose = "1.10.3" +compose = "1.10.0" compose-icons = "1.7.8" -compose-materia3 = "1.5.0-alpha14" -compose-unstyled = "1.49.6" +compose-materia3 = "1.5.0-alpha11" +compose-unstyled = "1.49.3" material = "1.13.0" flexBox = "3.0.0" autoLinkText = "2.0.2" @@ -43,8 +43,8 @@ processPhoenix = "3.0.0" colorPicker = "3.1.0" reorderable = "2.4.3" -junit = "6.0.2" -mockk = "1.14.9" +junit = "6.0.1" +mockk = "1.14.7" [libraries] android-desugar-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "androidDesugarLibs" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 61285a659d17295f1de7c53e24fdf13ad755c379..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 35498 zcmYJZV|d(c_x+v5Y}`!T*tTukwrxx}vDLV-Z8x@UyRrTBx_`(2c;3&e*?X_c5Y~Jq5mmz5jvSG{Od97tU`OPXP+>qbcF10PeF@RU@oSZs(ke|NfDZC$1zMoE*m!hNL}YBz=hV=S$^_F>V3I{gY7)1T%b*Lnx@~#5gFTC5KedGdUJTX`UrW%qQmhgJV$(hO{xU6zNTUMJgGt`)XFRm@RQ=cW0O63)px9 z;W6Pcj?_6R)iXc7PLZHvXf9lxCU;IuUoEn+7PZn!p71Su^>H(1%oTHI2pI#Y^n%nF zrIpLvVe$LElL_uuzCpj$2*lzn;+OhC{P&-M_d#wGe**(^OsWgQPs(B&g>L1xAip@(u6SRMdf$&AxlQ>rKGo|OOIl7tgU}z1gTEiyns9@?Rpsx zUsCN~HX1|iSCkDN@xnUHQ+}yuD%HWGA@;BPrk%5U(D^lW(?u%^2?XyA0>cB08gU@B z^BNz9c`X-2C1VuZ9GdWUK{hp+q$y?YnyGdKkdRWD#Eib!cZ|`lMAk<4s6nK3+cs<* zrYnXgJrsJ_TNJ&zX~t^MyUPCAc^qj58cZRw@bKc2H>zszL&_t@qJv`e`Y_4EjQt5b#Yh=XD_#a`_O@8utwrd9tdbS3cL zF4~$_%h`j2fi)xrq8k7&L=6MuMLaKW8AxW(Q!d^P)Z0(NHp2FUegjj&fYC(s?}mzg}(-{(!?H=ElbA ztMdcc>@N_kaAiPh9MT}nXQbu*1YF5^WLu#(Y0sdrpsWsF)+(T$(M6b?0Bh>m27=hA zC1>$8ZZYm~DINWk4niDk1$D{0_xznD$;ROkFI}jsE>(zgkw^!OaAI!_++~3uw`y0VJXju zGlqyX2_TqiG;kcoCgiNTzz@^!S=~O2?76x>N97>yG?{wGgV-PV4%0FZwH)fDw0D@; z2$4gBn0@1K55bSY_rq$0Gd{SIxQROYe$55{~Ck4buZxw$6j64YDEFEdJ;Cvc* zg39!R*fxwu!k-fMXvVBwg~f?B4S3vGoV#u#mEUX6K(o#`9*!Il>%WWv=ioDjjHIo0 z29^OaYdN*V)&Z==Oa=S=1d1Y6b2KI+L&Qs&{&J-nokwsJDk@g@OW5N31O-|GlWv8U z6SLN6aAx-ja+uBN6eActMh7%|4#}T1_=+G~#4{P+%jY4-rv1z!NVxjd++Qp7cqWC< z&5l9m2HISAtltymnr^rMOnxt$8O@-wMc)TJMQ$^_9Yq%;c?29u)mLJ6BS-Z7w{A9- zFzHD`KkR`D^M)Ay`hxIHlvp?Z*qGS1H0U#2zc~Kr5dU_PW|n2WYEP@08=rIk&GgD5 zd*2`+h>l5eb2~XeXvV8&0Vy`||Zc@E)^4JPl=KENu}M`yBlQTSYNfDj=;H&*)xi3VR%qpL*Nw<26fRh6HF&DA zfa4Cznu`+h$!K!C=>Y4@Gf1zgb#e2GYR5rzAvP;!iS!7|BnG6@-{X)hni4hDbDIL0 zM!ZUn&h|2l;6c&*sqdy>VVU=Z>tYHz8}6Ofr##WSj`iG>Ay4 z^*r?xF<}l7Ukl%(;9rlUCqfJ&v|}+L5QspZ>~;o+GbziEJPKCbJ0uprV4oB%{Yfg$ zkp2tKa1unl%w{q?6H9u8Tq8riS)D-%;U_A&Oi_6GpXB?Tug!ufzjrX8!OfjOHzNb~Gut!iB8VLi0?%}7E-oBYlEjPh_=Qa2Rn!<(MD4)9 zr51V3?Z#-xlXuduDxT9+o7CEmia57f$z4j3<88BeM^Ii(LXswl_arGqT`i6Ajf%c+ zD^fbrSkD`?%y5!;$Y9y!zD@`b_@-|88$MfI71ds(Ete$zqprErQU^7m@Q|#@FSu2f zMPWX0R5mNUQ5EXRZ+mc41JYx*C2Gv+3;>6J7aD!b}1VBgk-WY~9K~#@-P$PRD~jzEW;} zt+_U1vZ>Bq0nE_O;=NF_3tLb7%T%S*z(Eo4mRMNPt}7g1bXPfq`S*xrB$YzG9h^pl z)<4kSH~2f~r5ge?v;FIpy-+VL2UVT%rg}yiakOv^)eu|TB1|1EOOQPu$Lo@jHiA%9 zVnK6jF-Eh*uENA=(j+C%7)eS*Cg@E=Rp==Pay5sYc*OB~eZOHHLo zF7bIs=Itf!{INBD>nX17qKcv(Aktgk%@uRa5$$iUppur?m|Al6m@z@|AeVRr7ECn| z-{wi3n6GSEQS)ZH3?~zZj+LHKa;@XcvQ<-w08^_l+8M0l9R?kA&Gx-5Ol*+c(I^TN z)u^^LR7akWFkz{d6YCI`;mJL6`;F?XJL@;lMX~k}b=s$cC1z$@@q?cRvi@8y-0}TQ$=kwHMlq} zWE+41IZYvxmVx-ZJmoF>KNL(`~l|g1~ z5WOq%9GweJ7aAf{JFLwQ^A|-b+y*pfAR^YAD7T)8m}-=?I=GnyXWkKPGE&KpmY_sc z19w`ZkjSXS(b~K@M@pqk-`{D{uoGgkPL^ED;$wn+xhM_xJ1f{qP!y>W9*?MlE^ z>LwJI*gI_wx?)ZVJiS2LH8GRW*Vce+yc6Qv*)^JeF{dnAmQL6_l{aP$T-P$THA{2K zG?HTGsOS9gO;x?{b*nE=_&&H85J@e#C7Dvp!i&Ma9Gw$;V9_(JwZ&@*hlqO{3*ms1 z|0lU$!jg{cmW02sz>nibL-5V}KM7(x?hJrqn>T@KfKX#oe@g?qAZ2e($bjWAj5g&FJIJwdX9s{Lx-OgCs zl?$896?SNJvm-S^laQ7hePhpya}tJ6U|idyrsfaGiuWq)YQ#!9&a#+%alN zC5KMhx-rI;Up4t6x=rF0fCU1WIl^bEnt#&WJCcP_V#%C40(2|$Ynll1-s)<0xWz5{ z9`co!m3K(N)d=Nz;1iW+mAW)fyP4hsN$*b6Y1u9_hurjj<;QX0NvcMz_&_45aZo_u z7y9CG6sdl2rco^ z?T<*1JH54VCQ65#o?o7DUcQ0HP4owNyzQ?K-$3%IZ=rF*onJoOAR`%%>wDQRu`wQ7LJggaQPzTwg5L@Za^j3b@SLfCd9I{tou<8bUx~fWg8hRgj`3eG;hv;dK%MukHcl=IBhAg_>G)NJ={f67_ylV?thzOHBUcC# z*Mc*{dIW%@YR0Fa99&Ca1^y*uexVy(|Dj%gEUWf|_hRKHQROz^mWOYcNTPN;h8jP# z(UvO2K_;rxZx;q5>OJPR%DLY%s{7F&KANcO@WEGww+_Eo@yWNs_@(nAJo0oU#ckTH z&6Y#zlN?z@j@{Pyg=|{ZRbP0f-{3=fhG}Ee?xZ zKB>6w7#_R4H8zFv2+3MJ>m&X}vl(02d*j)yoei-e!KdK3Ipiy`1uu}A%^vo1KkJ&@~hCRz7apeoHO)RMhsT_G{0j>ECI8U+6CLK4n zqZS9(CmqCCP4B}pg=y8MMRL4Q$#08JflQTQEg{i#0`r3%Ex3k3v!Xyz_I8WMjyfEU ze?|Il&jtc|dE}C^NNs_tHVX5K0RQi}$uUmY9N(1kg6wV0bRmbtMo29s9ccwgpF^|U z!jvs}oY#m3Mv!kfo`sOoMA3l(0%Ona5QnLia`HYoPhPoC&RdU9xr9An(MEy0ccY`) zuLT*$uicLDXVK+XrBZ4PYcR!wd<`d-?sY$5)B4ahFikfOtBZkG@P6BLtJT~M{d5|n zix46WFM;N-I4`54N`AIMt;~LhJu3CI;2V0?F>~sipi@{Px#6Gpijrx@s5t}#gz`2} zPNn|kFeb1ySTf33HF7eHvY15)%%lvO#6>#h)^(Qa8&s9?%CRyUBY}$$0>=|D^b84Z zaX!LA@kCmSFCd@)&fbPfyte5QpbdWC!q z0skj`5F?IosTjHlWAk4K)W(p!pEyE%!rf&td4os8UP7VLHFJ!h*p)E?fdi^29&zi< zyJ<7_&m5r$x>hGUPNg`Q1CPx7-%rye z;EY9$Dt{u91s z2%$RJT9a3Bn*~#%C?dSk$AI?C)of`DQmP4SdlN6Vj_k=!y|pPbcJ|bzdc@7;xuiZ6 z3DvY?xKp5FWWQwSZ=(b8eHv^^fJrGwNQC+lh42GK_T|u$}lp~$rbdDF-Jwwya zTjh3te?>`vUM4)1P^v%xSmPFZqk=|1mbA^uf8i15m}Y8vL7&$VF>U{|u?&>vD^!!Y zBD%FB4Rx2Uj*9Pgjuvdo`eX3lrWZkCldSxMKhjYDW4ADr62M-0TqYF!r1%-Iz|bsQ4RQU^my>Mk={$PZmfUuw5BF{U= zUJgfI=9iaWpAZ4xD;b|%{5}eNFb8yX^W}ps1QCNC1P`=`Fg=);jZ!HjJB30wh$BSo zMVLgLti~sx*MSh#wACl&F_}*e$YYfI(L)mynYl+AqR$H@f+@{&|rcqA;0ZR!=_c>J&}{K?*orclanXd?wtc{`{dM9yDA7Be*157#GEX?`Tr2I zANctkmgBLTW$l=Uf zr{=8q3Oe>6Vc!RIt`p=)R=AV*d;B{xQUl4veC;{t)jSh8uBiI(hh3KdAYVDVwo;mT zK252?R{eZYoeUX*Z@+L#$-90Nq8Mv|zJ^X+*aGWN$){%nNIf~dVlhNqh3Kh}y%|dg z>b*|UYrt$NEKQ#)vwN!^=d%e*er$yg)!std%RtW8!y+bC(KZX?9zTlfxO1s1P~Ohxf6^BcI4Mq z&tL92H0qeQuRLUK&-g(!f_yMU7Lja?A1SUB4)T_Q$m`K?X5!7F0p4C;0f zVZ#3#G={fBa%&i=m|%f(tZ&}Ra2K-XGpNY?uvM`$#6NbGKj&WQHDnLA`W+5PPA`)3 zh=J)BM#Dr&ar3{pq}$?w`u(4{MgEap{GwAiJO8*27-zT%KI#j=DSpi85AZZ z_`o;OB5Bm0LH|$Ov7MQcuqnv944gy{e_-wlsE3FYA9e<=AH@u^YiW_kb0#4!_;R4~!}h3g3xVl9n}^i> z#oG!&$xjfctoc<{Zgq81ZOsqPJv@q$;97Ao=Lh-nh2o9M6d3sVlchffb!-Hdw1uKY zzMg0qp;Kb9H3J2TgrVh3k{IiF)dBEi{SZmTy2EzL+`H@|9j}PlS;r?r5keP<$X=zb z@_qX!XkwQ_=|Wx_*6CMFl)xq2<2yzKLh2o%P@%HV3Mc(QY>Q=Oe|#dT)%Vsb?q0*T zt z27pe=goFDJ!-q$-ZFbQjyv=TG<`4ZJhA%YSLniza#ymwQhD&Po+`!^tK9$bm^7Q%o zu}=};HNou5&*`c3S*p?2L;U6m7;ny(nKV|z$JY%=ogH#K3LCQrWLb$FGM7>4*j6luR!?%rl&z)cA zbs4{u{gwo1gZ(qwMon67&sWeU297xZiESk>NJt;8zl3nKkg)F` zh(n2xaOf!=L`9IjQ#giZuEL+;;!=xLp2Se*pWEJlg!Ttensrid4UbzBHnhEaVWq=C zP?vcT|G*(G1ZNSfs!FZPzpJNhEiZgp_re~tc(Bi0*&?;-(xBQtKg)Clz* zt;qR0edJ4%9)H!0;c2BG3WG5;Uwn?P5@^@$x zNxDk^`M!O6zexXaXuVnc_e0r*n}hdk?#!2xk9ohw%k`=L->EFG;|Z=SuDeUZulHLV zL1b&hX#&2C6Cg_N?pkN<{j;EMW{k%WTZb~6>?L+{3b1^o{bvV>Y_FbBl-Xr*WBtt0 zruJYA@`Up|X7-IWmD=uNGLhfJ{ezngUkTj#ea%H~RXREL2D5_Oys9QyKUyDCC7Kpi z$o`y`>DATQ#hJpXG0`U@6XRb#3r&zmAW^(!52_IQV_4pAV0xZcCNdOEA7}UxEU{+^Dw3rUY<9uI z`S%QV=E)3<${>J96uoXgs<%1abI@>C#yOz}P0-UU&JZeI%+F}QxkD$=(!fQO!FDzO z$S?J(TSsfZLV6>CAS(RH+hg^QK z%73szbu9!|593dFS$Q7PbuS4M8~4*K?-C=8XiQOJzTk=34^fxf4(Lpjo+QiflsAb z%=Yk}(Yv1h^W?$o>9ISn`Q=esXEfP@FD>N?{wW(aHgX3Vbo-pX3G`-xfZfZ|T{IF3 z^p4{zheXD)Pj34Vrie^Z;)tq>hm9yB*?fkB;rjU(yZ}P1P)qch8Uv1SM=>M zi{w`rl@q3Nv2~2$2hYZQ%hOmp4X&D3%GF$g*6U_G^H}+Tbqe;btn{upM?{pcK-W)W zWt)HxCaM5atJF1V1CYmuSAclo=)Jp@`$R6ptf>?BtOlnhp4L#_fdc0{5aIy6&*yw) ze`VhUSrj+bk@oo+YQfSMDgsM#saIhpa7yjk07DVNFO5e(7g?8c?G?XU+8yr#8@793 zSJM%WP@um_lX0yfUfV_9{NRMptG|%GxqX}|MNK* zAULaTJa)(dKyCrMDo>>3sJeLoH_3|Tp*no{TCPW1o|=>Iiw1m09QIiBV>2pOC#p+d zKg^)Wi`)IBhqm8J6UUrWI_5~M29e6wFT0_-ncb+ZZ3v*{+Bi&Y(F*hEW8Oe#48ywc ztL_Oxx(&-Ipyvs7PRJi++CXdh%XwCiyc;>vE!NN6X@giY)M1*_I=q9M+gh>%0GUd{ zG9$X(Q|%T;8v!9PA!oMZ^s_Dnm6+iyOtUa4u_3@8iouKQyTn}FtQ3SLhs02CprN5I z?LYy$P7)8c60W?nbGKC>pinO*eU!8bV(-)y8aaSec@o-A$*%nkK0h4kUziQvE^kv{ zcWFV9NJsaS*eT{7b;#?I4+_Bsjen6Pkh`l7Wv%G80ss6pqVE?GQ)WDa2nl5vz;d5d=;_QR9hj_q)wAu(q8Ft zv+V;&(zxX7REG+;cq$^|$*eM>EVu@hEyI~SI~y6=QN?iu9RbQQGr2rf+LelNV3?=E z^@lwMFwaU^GKw1)(UvxVr;upcKBwJ12X_=>Lrgrxx6lx(f|)9As#kV@WGYO2Q?vBL zchT=6Oz|1~IRs~VU0#VyyxQIBy|+i@w!GlY^QQ^idUEv^OuzB6-!m|hMBA5?P1zDF z#dUE2OEHw(8FBYVbNEY;HT#p%Mcxo@5P6O`@T^GKq~KC>q`oVD>TV%`A6rmAyhG-x zDau>|I!s8yDdo~ycJ@-{{%dDAzEyt&2MtToP7Q}f519>evw~B8n$PVu@QYH2^N+*Z+qY<977=cXVMbu> z&Rv2W#NT;$v8^K9L8B{!(EUxe`Gj7m{Pz*Wka#1((p4wRhzpEW$c*6cKaLS?NG=Uq zihxBDZ)!?wggNiY=eVte&v}SqJ@2+8*9=$mh3e>8P_A8z=ez#+A2+l;5^ClBXD_?| zwX=9h13WyyN$Gw;l+UH|vZl|*vqh2b`NoK9$;x6Vy-}f&K|4+z>BTl2Qmbvf?Sc zG@)RkqB{AAN_i7=2~+%Ooy`W&danJY~Yxkx~5p#TKpe%^L@r}O5rB-ue&T6ub<(B+^b zCx!Dd166cf=EX5S+1V3}CyMe5vFszYC5es8q@)?8S~F&Z`7jt~0&nY6m+J+wJUp`F zjYhsofr4*qyS+)7Jf~$mZ32m!J zj7!4D%8Fl;Q>?rGlfIiL+yq=TZu!WHW{y!hG+qe@t)0FwUpHjAORPr!JlyTL3~or# z$_xTKoJNTjV4aTmp?E7#`%bwiT+I8}VS4M}xYDFF^jN6OHD!F~=2Vx14D9x$S=X&R3Qn%i^UsUg%9C&9e$(t{d9{+IHy2 zn4sQ1=Oe;INItCX3*O(&vOC81X7X1TUr4T672DY#2OGIy-{-p-B{gg`vRFM?ss4Qz zJZYt8jP_tjcbs%I57Yl#d-ptWU-E;C(S0(@*NK^v%zew9ZPgjXd{^xCdLL1=pRqGu zSuPxR@r!Q^?VMb%qf{`$LOehQ_Cv~gV^_~!BKHlS^c=?4I)wje;>X?HL3z?Uk1}?e zZ|cU`9YNU#8m~3Q#_S;0ooR3X*vYrarbJn8GKV)EYYIObx%$<3q;L3%Egx5FoK0uj zvC*Ty``&fC&3MTsV?W;Rc?-3avwzkTTAh2sBjs&5W~n+XX#m%vD6VOUPF5eAcMts^ zHCSe2EdTcJok&*FZ&LguL#hAt@jXetnh9v8*eky-h~lS%CM&Egndcke69{V(T22Wb z5~AosUA`uL-?=55MRxAiXe06SAKew0VWEJ8iN$*^EjNiOhk0Zy@Vc7JT=jZ&`2sV< z!UgZCuIWLl=@TudT2*Q#EO%{P^$4|u1IeOvGREdVi= z1fRM=D3-u!nUa$vc2dXq4p#YWJnYCOx)Cj_Jtmd9(Z%Gdy*R9~Wc}%LkV({(FK+5;|B<4Lf*+yp$HSS#3y)#MW&nE6a$3 z!|InM@6ZC#(2%*h+6{Oi`h?GsCXT2f|D@cfg<`YZBTX9RbD83Mn(+O)Iiv|ds$}%# z=iRXLL-f|gi@$`?2?RjK2o_3_W416a8YMoC?t(CGvNjImkMjVDoFh>{Qsic6-NOag zdQMr-A7k|r4lXWww!AL7^W4kG@I`p>$X>0N(sOkSqT**Oc_hqjcg~&_FO944Z>;eA zlR-?XJXS`KU5SwZ?Xrl1mFw>O(pqIPWgX^>ij&}7E%BqGiK>LId_3Rj(hJp&so1@| zKE|GD30`I0;o0>qez5y(hORuZcaQQ?fI1Uy~5$j9fnrPE}*ZM_0rGAZWK-Jk{g zUBCue4U~SYEb|tWr~Vc8*@aA~QU0r64K&WQ8tbz>0Hs%6^C|bl6c?K4`>}Cq%Ze;B zwUgGFln@U=wdG{uN2|cf0U-Dgm^l?}DKFh+?{AdTLwvG-KoC;)+ZgOhbj{7HzvynM z53=&4OdAi>YQZLGFOPsP-oVZ>tX}uKdj>2IX}*I7(HGinVwmg+_NM^Iu{aVIXNi7r zDgyrl^h0V&M}7ZBTkwCQwULklVoB|^JJyoaXnZHGM;rJ?D*x}f&|W}mKNOj10EYj@&_L?dBG=1Y3tb60#K(9gbUO%Av4$;f*!Y{%(zsF^ z9E6tC9llqUw43J&l$8VWS*J%^jYtVsx34HPHCA$FgPdimM+~CPD@}srLW41_%Y9Ez zOLe`Y$c03V?h>5H`PDUTfFAlyGXpW?XpN?V+AjE*mrc`OOS#!!t4z`3>sfC4GmV}&5e0UwBwgapAF4oZM;KP{?=J}W!pl3%~&Yx=(xiUBh-LwLJBgyM*q(p&sy*K zW0M4=oPbIW^XeF%mvakMPBPNCl&bNI1+;K-A_yUsfk^70WVEF+A+ZGVI^4G*r}LHF zHTeXT&g-H698V+U_5GZLC1tz0A@ro?FV+iPh=NS8$yDEe!s|HZIri-azCYa6fTdn@ z^?M)_RBBwM+u7T}ZL_DzRhUd=s?VHv5X5c#0Wv&*>nQ5ND_kqin5Tu2RnSycFS!$ppIIa^XeNrJHN|bxuZmJ_p(Yz?eowzfIWTvE(RkE8W6iV z>st-A9{mAv_X!IK-a<6Cim>^|U9AIM$6^nfDaO_lpWcL1YoOP=u^itH)Mj_u)vROyQRDU;i)36-*hRQ|+O<~z-5bKUjL|*V^GF14 zYBL`9Ie>J2xi~Wr(NP!746oye8*B@ ziI)%>9;Rf*^Rt-7IkMuW-cwn%xP7}-WT~gR9O!EiwG-$~ft*~=`I!in?3U~Z1WDe~ zTnW)pEogyE98k{d2;2B>2K8j_7rSO0bBw%!a2y@XEAq;n7>7Zz@a3eZeXyxWc!NyY zuB{`C=~+&}&VF_V+;cvbp_lOez0i6w4Ey>zBbn5MKTfm6#|jzqNXZ?9;j%2nZWBKQ z%Rs^RU9uH;AWCrAA36pC{dx01o?pg1oLOtV_w%BAaB7n@*SUaVEYDxKe(!wURG%UJ zJq0PVERTN+HVO>kS#-9G?XP+_`gO&h>^*c+?$K4@II`uw0-TvL$$`|FTm7ecwB|?D zr^l3L^BtTXQLw;)l?MYwE6m*8q;2PhxLiZoUgSksADM)v>YhKAOdK6NQ8Ef;)7V+U z2)R_KXg=ST(K;d@2G~z4X!|j#y|c`nv);MRJd%8036rb}KmV63;#m1zssAO5ga0K9 z5;+mzy7IgthF+MC46Fv~04|Q=ys*xI2$geU}}hVnZB98 zVcXroF=sYJxy$PF2wF8|ifIgbk^K!+{;PC#+ni%@Z0jM}_4^@g?fZIN%alB2mt7O`-gq&;HL|Dz8R+RcC7Cz)8Khw7m-o=Blf#Qx^t3Vx6zeo2 zQ9g01-}1S!7xrYqIIz#&1Eq#t5QmK|`RJNkvt#Lex>ZVru-Y*#TL)+KbF#-W)um@f zx0HStU%9_>_pp2ijNdRT-+tNoSzs|gJPVRairZ}pc0Db8L>tNaDSN; zfDz1@@=+x0Ix9oNW)vk>>OY0Od?uzJh&OKk{T7J}QPvdyhGOvmnTlFCRil2Q~IfEsYVcxu>R zJ*FHyoAyMj&4ERYu|#=o?(^%cfx=1pL@-2|h4gewqnG368Jp>5=Ik&j-aqvNA|>e5 zlsc&+*I+kUMSojcVIqs(h>8uq@hjfs`#YF(Y?9jiRk#~>=y~aW>fZNTe%msc7I zE)h2AI#+3lql!jeQzJPTEJcQ?8X6ram$@XqY**MNZW0S*%$7vy#ZwNlfjiXJUF76Y zrITMkwf|KZbrO^JkT+x)9jCfM8_49@@4Xg&i*FsoQaKajDYBtx420Waw5BY>;J|4< zEzlO$FeYdPNI%h#NQ(&1?jbFI|9h-79*hP`3?Ybf34Z2*hdrsq%8cMLJ8=rmG!L`Z z)-qLvInC9(eE9?1Cg zVD46{(@u*UF8-!((DNHL;QelyuhjHf@yP9lX}tN>n~;7Ocs5un%oZ%uFgosBf5#j^ zx|}cnx57e`d;BS7eK3L-HbG!-!}@rizY^64w>H(b`M#Fy)}>lEOxk(bBp8dpwyM2zc5$f;#-8vq4ZD-}6)WQhk z{8RfYoClFCr!e>##pPt7PI>VT{n(e%?h7|_|E*P#f536H?IDMii*vLkPRvW%b2a!w z6^RGKx`JtS?l9T-moE9TlqBNDoVEb=L{8>4E>lV$pycp3hhJ_~+;#Um?#f&~9xcWhu5g2- zoeA{0ZFqE6Y^dmiU*-|Ws&hI7VmlJSDp;lVdY=0{14I3L z76mGXd!lx0zc2fFvcY|@g4e&dg>;OP`^HlbN-C;s zfF&CiiOOCY4h}80Zw5K|U|JDgOLv7xDoZ1 zFlw$Yi6Is4dLxHRn)TBJf!X`|=Y#1kZRUvLg8}pKmiQTeXjtd3p?+0u(?fSU={r)g zN2!v24`*)n!`(%w!i#hYI)mEG3_etU!NMy&0AWPtsMo23#R z6#RIygqn?IV4s?t);!&+Y>BdK!#tTpqm#Gv$nw31^6m#E2f`Zp!KSi6v507jD63oz zAwAC25~ozrr%A~G>;rFh=Uv0!Kd58;x}@*KI$C)N2V8qKmO(R<@x`QVz>sZ4aCGK| zXC4bBrhE~!&!6TKp`+1=!fSo*OgEAJ>%7uEp@JT}1(*t%_RC#xz=!#_VQ*@sgr>)8r*DJv>t{%-O_ zRGh_{*nBuO%A5EUcG;tKSjX*Wqzum{^fsZhKM}j`x6?k+g+!3(1KDC6MI}dm_hip* zNm@X2*pBWL$yFof%UvQuXG&Y2pJ_nre)ITkigs4_XoV>8KDs7VNKh9_Tig;TL_pP$ zM>uY$DSt)sO~Wt+$xPS0pV~VTfwEJw-(l%@_5qK0ZS~`~#@8yU4OP*9zphJLU9n1* z+cy+dB)=I$_q+(X045xtE=Lio&OKQNnR(*tU%{Hej7pF}GSm)km`8C%@DS^SNZRk z@UQT190g_y&Do zCD)BexxEeSuE2K#c_<_)OJud%xd@qpU!1}1GXEa{m_TR0CE*JazqhHR*#B(U<^N0A9SoNQ zTwzbZ9hPdtr6qOYQcr!@|6HKt1pb+;s$%*rLh-)=P)i30;icn224)EW04frbp(GrV zfftkAS}1=(6g@*L-F~20QBYK5RVWGD4H!v-!~~_lLk*^-BtA9M-P`Tb{mSfa4KeaV z{1?UqjVAs8f0XgIXpG{6FEew_+;i`_cjnvo&tCzoV@crM>1ng}M(;{%K!L4q>Q+x* z)veHvTu&x$7#MzN6Z48Zk}>gRU&e;jCu+o~tXb=i zIabwv>3gZ?F%kErvBr=B#|?;-8#v4kNyS`?`C9c+wPx5f)Zc0l0)W@rE4MO~oW_^oIqBWF(pv@OeX12=gpkg2R33C#W-^elBfn^X=Z zfyu3LYzdc9EMN*(1oA0ctM=KOhO2+LYMsOh`8iw@C_0q9R3Z11oCqvcE;?DcNR@CM zHwu`+EEgUPBd`UG|I+^S%qec-*2w5QcWPf&&qu4_4x=PI4;7fH{ImE1?v0d-C1}X! zaS8VYvd{Ukvx^LJ{J{ig=ezMqLjgtJA2M3T1fPKUFPM7u5!2=JC(NDUcKI$ZXV5?3 z!FymV%kVmZ%nwjY2MDcD`jnI5Tw8w&d>m!9KWFwavy<&Bo0Kl4Wl3ARX|f3|khWV= znpfMjo3u0yW&5B^b|=Zw-JP&I+cv0p1uCG|3tkm1a=nURe4rq`*qB%GQMYwPaSW zuNfK$rL>_?LeS`IYFZsza~WVW>x%gOxnvRx*+DI|8n1eKAd%MfOd>si)x&xwi?gu4 zuHlk~b)mR^xaN%tF_YS3`PXTOwZ^2D9%$Urcby(HWpXn)Q`l!(7~B_`-0v|36B}x;VwyL(+LqL^S(#KO z-+*rJ%orw!fW>yhrco2DwP|GaST2(=ha0EEZ19qo=BQLbbD5T&9aev)`AlY7yEtL0B z7+0ZSiPda4nN~5m_3M9g@G++9U}U;kH`MO+Qay!Ks-p(j%H||tGzyxHJ2i6Z)>qJ43K#WK*pcaS zCRz9rhJS8)X|qkVTTAI) z+G?-CUhe%3*J+vM3T=l2Gz?`71c#Z>vkG;AuZ%vF)I?Bave3%9GUt}zq?{3V&`zQG zE16cF8xc#K9>L^p+u?0-go3_WdI?oXJm@Ps6m_F zK9%;;epp{iCXIh1z3D?~<4AhPkZ^c-4Z}mOp@Sa4T#L5>h5BGOn|LS(TA@KB1^r67<@Qp!Vz z2yF573JoDBug@iPQ=tr2+7*HcE3(5`Q%{A2p%psJG}nJ3lQR>^#z-QI>~|DG_2_26 z1`HHDVmM&*2h2e|uG(OXl?228C|G32{9e%Onc=sVwIV zZ=g2{K5s0>v2}V&CZi1_2LA=x)v|&YrWGaHEe3L=lw}aSiEdWu&2-C5U0O~MpQ2Hj z-U8)KQrLg0Wd|XyOt&Gc+g8oC4%@84Q6i;~UD^(7ILW`xAcSq1{tW_H3V};4 z3Qpy=%}6HgWDX*C(mPbTgZ`b#A1n`J`|P_^x}DxFYEfhc*9DOGsB|m6m#OKsf?;{9 z-fv{=aPG1$)qI2 zn`vZ(R8tkySy+d9K1lag&7%F>R(e|_M^wtOmO}n{57Qw_vv`gm^%s{UN#wnolnujDm_G>W|Bf7 zg-(AmgR@NtZ2eh!Qb2zWnb$~{NW1qOOTcT2Y7?BIUmW`dIxST86w{i29$%&} zBAXT16@Jl@frJ+a&w-axF1}39sPrZJ3aEbtugKOG^x537N}*?=(nLD0AKlRpFN5+r zz4Uc@PUz|z!k0T|Q|Gq?$bX?pHPS7GG|tpo&U5}*Zofm%3vR!Q0%370n6-F)0oiLg z>VhceaHsY}R>WW2OFytn+z*ke3mBmT0^!HS{?Ov5rHI*)$%ugasY*W+rL!Vtq)mS` zqS@{Gu$O)=8mc?!f0)jjE=p@Ik&KJ_`%4rb1i-IUdQr3{Zqa|IQA0yz#h--?B>gS@ zPLTLt6F=3 z=v*e6s_6w`a%Y2=WmZ&nvqvZtioX0@ykkZ-m~1cDi>knLm|k~oI5N*eLWoQ&$b|xX zCok~ue6B1u&ZPh{SE*bray2(AeBLZMQN#*kfT&{(5Tr1M2FFltdRtjY)3bk;{gPbH zOBtiZ9gNYUs+?A3#)#p@AuY)y3dz(8Dk?cLCoks}DlcP97juU)dKR8D(GN~9{-WS| zImophC>G;}QVazzTZ6^z91{5<+mRYFhrQeg|Kn=LOySHXZqU8F1`dXWOJ?NViPE%& zFB1@$8!ntuI?)geXh|#JJC1+G^n$h4F)g-P4WJMPQn{p=fQtw0)}uk;u*&O2z+G5? ziW_=1kTy(!AJzj}de{a9WHY+*SqJ7` z={VTi)3NK|)*W3PUT#5a$D6oyqH%5zjdO$5ICHx_V;1Z)4A(rT6aasvZ{{r`HnxK7 z^fMLS1{;H{o<8j5hz*F@WkKQmDI*Q%Kf$Mo!EpQ)=HV^lsj9KSz->ROVI zrXAI0!Q?WUosf8t6CR*rl382^sU3q@($L~EC(AoyIjS&2(el|I$a*8oAtqGQs+O~huhBCOFw(^b&bol)F zWsp15Sra3v%&#wXz*!kSi!sV>mhe(I z=_Zxmz&E1>i6=yB*_X4M#ktdNg7_G}MVRGQ7^zX=+mQ}1xtg7JN9E(QI&?4}=tP2#z2<7N%zf9rxzynL~ z!MgNpRvXaU69c*^X2(c?$=h&o~Fvv06*{JdsM!gF$KALcW(}@Q&Alo`@ z3h!H3j^@5rFMp8l6-q!cb?1iS$oZfU+}A2<)&2ZoL34kkSnbf=4>qd%guV7zM1p=amds@nhpkK7 zmRJlb?9zYI&?4ftd8+RvAYdk~CGE?#q!Bv=bv1U(iVppMjz8~#Q+|Qzg4qLZ`D&Rl zZDh_GOr@SyE+h)n%I=lThPD;HsPfbNCEF{kD;(61l99D=ufxyqS5%Vut1xOqGImJe zufdwBLvf7pUVhHb`8`+K+G9>llAJ&Yz^XE0;ErC#SR#-@%O3X5^A_ zt2Kyaba-4~$hvC_#EaAd{YEAr)E*E92q=tkV;;C}>B}0)oT=NEeZjg^LHx}pic<&Fy$hApNZFROZbBJ@g_Jp>@Gn*V zg{XhVs!-LSmQL#^6Bh-iT+7Dn)vRT+0ti(1YyOQu{Vmgyvx3Tuxk5HG!x2a+(#>q7 z#Xji%f&ZxT@A*$m8~z`DDl?{&1=gKHThhqtSBmSpx#kQc$Dh6W76k!dHlhS6V2(R4jj! z#3(W?oQfEJB+-dxZOV?gj++sK_7-?qEM1^V=Sxex)M5X+P{^{c^h3!k*jCU>7pYQ} zgsEf>>V^n1+ji40tL#-AxLjHx42bchIx9Z51CG4Iboc%m0DAfvd3@b}vv4%oR zoYZpZ*dW?+yTcduQlxreAz&6V(Tac9Xw3_`NotT9g&r{F_{!Xb%hDPJqn`CWqDwai z4M@7F4CQ?@C{H~rqxXwD(MFpB4!uljQmH~(TXJJj3MEVHkt7r8!^R;bp!H=&%-OG& zONKIOgLJtng(VD0u9%2LuXKe7h$?9lQ^#cLOo}gOx^+ixt2Izmb6{J`u0VexU0j}8 zIs+?LWLGvQ66Pg0ax4n^G+xW-rwp&fIZ0}lI?y~wn^6o3{jj*VSEQ}tBVn1#sVTQB z(l&Gf(sriC0DKR8#{);Sgb5%k`%l#BfM#W|fN5C8APnl5w%nrNi{BWrDgudYAZLGE zQKTzz^rV(Bst!UI7|8?nB_w}@?_pYX_G?9igK?yo0}({MC^6DiO!bB88kijN>+BCQ8v!rg{Yz$`Hf$tB*WdxSPHMMkJ{&p0(lyXx|^X_VUQBdh9)?_2P1TViiYqy+ z91$zg%3%OjzWyY=X^f7I)2-34bDVCEhECAi^YqS9x@(kD(Bto;VDKfgIo-)s_q)d2mr4O;DTUTgjOe4f51kd6T9 z`xa6_AUP*N{jz%!Z0E!Dqq}JlfPZ2EyGN*EoPHJ^rT;z^0vaI03Z(WcdHTh1suHxs z?;>yWLj~GlkAQ#jSWq|nUE}m()bBZ1`Rh^oO`d+Ar$33kry+En{&JjrML}&gUj3pU zFE58(t|p~g@k3p&-uvoFzpGktUMnQ6RxDA&ibYl_A!{@9au^_fB@6;1XHLORS}C(H zi&J8=@>Kw66&QJD@w>_I1XJuBW3_vn?f~bbTv3_J^W1+E?921QNo!MQiLHISD9?+d zP0BsAK+yB?l009uXXMOteoGX;?5I|RG_v#Bf~l?TPy3zGkT`N>WlZRa=k7Vdbz-66 zIQ979fX!i7Wen@lu-oEcweu$76ZXrc&JWRf!tLRg2JqNG{;`-H@L`KHfgY-Lve@vsPT7B0@716|Z$Z-Z{!W zV;qGHV!`h!S>b)rZpc`9J))^79ey;7@-=zZjys+j=U6maKhDddqZ}XQffIbFYn)R6 z57nRGEG#j`M-Gni4deWVXcr=HoNok4SKTPTIW&LDw*WrceS&Wj^l1|q_VHWu{Pt** ze2;MKxqf%Gt#e^JAKy{jQz4T)LUa6XN40EOCKLskF@9&B?+PnEe(xB+KN|M<@$&ZP{jM;DemSl!tAG2{Iisg ze|}6`>*BENm!G2E!s_XsaUit2`a&pfn!ggt)wG<~NoFFD~p(1PRvhIRZaPhi})MXmEm6+(X? zAw+GxB}7gAxHKo)H7d=m&r6ljuG2KX{&D9ANUe9Q=^7yych#S!-Q!YKbbka8)p==A zm-8`N5_Qz~j7dxLQeaeCHYTma$)Fy}ORKS45sf%}(j`4U=~Aq(!-|ZRRXvQijeGJ^ z%cq3itmW;FI)JsU8k4pNmCazDyH9@=bqwS9q)y8?KhH}MpVTd^>?u+Cs!&l|6KH<* zpikOqr$wK%YZ7(>z%vWLb^+m&cCQ+h_MDo+aXmPW7CD|K$-d&cg$&GVPEi#)hPjGY zx|SBxatca)&Ig?*6~uiQKE)tF7l+ci4JvbZ>vQo}1mB z?m;{w?j6>1xBD9F+2p#YP3U>vfnMicQVHdhK1yDCfacJHG?$*G zdGs93XO$LkB~?nFAfNOoRY`xRs9JiG7CM&Dd5!=ra;zY~qn6HhG|^&58(rYoNlP4q zwA7KN3mvymz;PR0%5d!IoDF1vxVxN zS5wG&fEt`JYIGi>i=Fq;YUc>8aXv_wIKNAmI$xs8oUc$5M((w)<+NMQ6{7X7iz)2t zqz$eebh#@<&91|=(KSq0xZX>fTn|!v{~LlTjaOXR{3kx zDZfD5rHpl>gbmAU@|wOa$t%grx`7}nA|ePPsN0Y)k&2=M zc4?uE@gW0-f>S_2bO;VnKt&W3k$KKdvZh@&*WWKa@7#~`b#Kuyw9kqdj%CMuQ9ESPc-)MbM#7}YUL)ZP_L{+s ziDWcU?e8%n3A4VsFYJpNeLjn2bT>CI3NCJi7EH$DX3S}9p>0NY z#8jZt#!W_KUc?R>k@Ky-w6=+Da+_s0GJldlF|P?(31@{B7bweeajQGYky;y%9NZK$ zoyN7RTWNn&2`?k9Jytjwmk||M(3Z!M&NOYwT}t~sPOp`iw~(CAw<+U2uUl%xEN7WO zyk@N3`M9ikM-q9|HZC|6CJ8jAUAst!H<<<&6(6Zvbpj!BrzUo!>VHN3A3 zvo$EF5-6b1Q~ajXENB~lhUA@|>x6=N0u#cfv&w(qgG`^+5=HoNur`2lvR~b&PjumO|P8X;=d`c+z1YJlY7&H@Dz-Rts$X0IYE9kSIlqGZ7utSx^+2hOEC-eXviWZXQ9;$Va+WlHlU%y|f~ zw(|)o@(5J0o|3MQ2O@+B<@r*H4*65)(r^JTq+<*b06XMGclsEElst5dEfFJ;AQfYh zRt}O0CVKdGh4Tk3-(^-{kukZb*3oM$ZffpGMs;jtk2ZjAsn%mND4R~OS73JDbj^Q4 z40{oS&4<@VUYMInc0xxy?FE@$J_^n)b|gY+Oj;8Pk^)6$w9nbnMms3RSr6q(9wP_) zv01|=P}UbkXoS_1#FCl?>&9cjCHOS!yEJqiGd`83Nj00{X6dHFN84%)I z^*MZ=*Ihw5FxD0YSJHV{j!9v(DT#k7##q~$87Dig!k3EiMO;k|9XhYz8cGVPukGe$ zN5@yNtQgngIs(U-9QZ2c^1uxg$A}#co1|!ZzB|+=CrR6lxT%N&|8??u1*Z?CRaGbp z6;&#}$uQEzu(M6Tdss;dZl=hPN*%ZG@^9f*ig-F9Wi2cjmjWEC+i?dU`nP`xymRwO z$9K3IY`|SvRL^9Jg6|TlJNEL9me$rRD1MJ|>27?VB1%1i)w5-V-5-nCMyMszfCx0@ zxjILKpFhA4*}fl9HYZ~jTYYU@{12DS2OXo0_u+ot_~UfZNaN>@w4Es$Ye>i&qhgqt zxJf9xi6El-@UNPeQ>aXcYVxOUA--x3v13e=7+%#m@}QuMTjN3n--=-{@rNtyYd zYS@LJ(G?*np*HILbUeo)+l8N#+F-;^(8w>i8Q6til8Y^NG7_qa*-n2|4}(k<-HF~R z0v*cP7bxlTWNJ1s6#Rz!NCYesAbm(}4qp%-;B%AF-LyS5Q6@Q|V z&Y2ar$uWn(?UstqXy;5$ZOCC_?L$F@o#dk--?Co{)CGEP^73Kb_^>`G8sAN)M@iNKQLBj>QAcHjIw0!1l6{UYd;|bA+CcC#3IGYysWLa4!KA}C zsEV#c)JpJcF~NX9mrX2WwItXv+s%I2>x#v)y%5xDSB`&bU!9COR@6LwbI|OQ&5mf& zL^GGZnOXEOLshxOs;Y;ikp^M(l-^>J(o0NIdbt5`(fTq>p%?cG;%aHXhv=-@!20#xf*q)++kt8IJ5cG{ zff?Sy9hfzQIroA8N>Git>3xOUNhe8nUspSV`GL0DK}<_w!3gRCwOvD~m+Zn6jxTMd ze<_?egr$S1OySh6XsS!0Wh)wJPX+xd11YQ=Mq7X2tU;U;Xx|ObfO}%y{pchi>ryaM z2zAy50_$ltt(ew6h#CF@+U74D#H@hdQ=dX_=OChf#oerWnu~l=x>~Mog;wwL7Nl^I zw=e}~8;XZ%co+bp)3O{Mryc`*3ryyIC*S%Zu;8Y_D3bFAn%8 zNTYv?y_%Q4zR-DvE(Q*~>ec+JSA76q7D#_wFR&HI@z>V`9-)x zr*ME%7~<$Ykd?U8uZ~EqUe&AlGDqP{uUvnavy#q%0y2VKf%UxO(ZC2ECkuzLyY#6c zJTru6Q`qZQQ+VF1`jr8+bHIwcJg}=iko8FEDt(bW8pbOr>?{5KLASE=YFFv&(&IM| zP6@wK(5#jhxh@Pe7u_QKd{x@L_-H zM=1`rX8`BDds3pf+|$)DBqpXr zDP>JcOxubC$Dy60;8(mfG^6yXE(+N*UWMW?A~?H-#B7S@URtmlHC|7dnB!Lqc0vjG zi`-tNgQ8uO67%USUuhq}WcpRIpksgNqrx{V>QkbTfi6_2l0TUk5SXdbPt}D^kwXm^fm04^i66Xn0`pLmnhX(P0|TezLiFcQ{E0~v*cmmAR2|PET zl7Ls>OakCexUmie^yDw3ccuqd5(wV_6?YM+egsV{M=^n{F2a}~qL}DfhDok9nC!X$ zC9WV!U15~DF2xl0YLvS#K!rPqsqS7(b8m##ZA(3F3H0v&0Z>Z^2u=x*A;aYh0093L zlc6LWl7U5kwXW8By76umJat{FC`H8^K@=20LGUu&PPftQfn-}R#6E~`;e`lZ_y9hX zI9nAF8OY51`Q}eZ-alU70BmAj;IZGoXxzI^8QfCba(CUJ?bh5NiBhFyrjpo;k`}RU zNRzb0n;mJrphLl}?MBw!ZA)#b=BA++$<$N1M{{R?rygu>Giw?@^X;zIEZC0p>fBNs zs+h>AIApa)#`0OLH#W958eWTf?n4PepnREhO+ZIVlfZIfLO(RJrOCfDGEK?&C$Y_> z)=S^{Fuzz4!va$`vL}5lXkrYW%bH|gUK?As5mHLYz!l)Iw)g2uVw^> z5BZf)=cdR%GlXhRaaGM3&Vs|i1g~@4Eug>wRMxJqUof@)jOp4lW}kooS{PUqJ^@fm z2M9!-I|6Hyt%6X033waFb$&wt1h|3@lA>hju-BAmfjCGV5h+8q93HYw5uy}QM_|d8 zm%xHt3D{+J7m{e#O4`V2j<#tMr-_uta^2Q+TPKZL38bS$>J__n)1+zBq-Wa3ZrY|- zn%;+_{BHn|APLH8qfZ}ZXXee!oA>_rzc+m4JDRw#Hi1R(`_BX|7?J@w}DMF>dQQU2}9yj%!XlJ+7xuIfcB_n#gK7M~}5mjK%ZX zMBLy#M!UMUrMK^dti7wUK3mA;FyM@9@onhp=9ppXx^0+a7(K1q4$i{(u8tiYyW$!B zbn6oV5`vU}5vyRQ_4|#SE@+))k9CgOS|+D=p0Txw3El1-FdbLR<^1FowCbdGTInq0Mc>(;G;#%f-$?9kmw=}g1wDm#OQM0@K7K=BR+dhUV z`*uu!cl&ah;|OXFw^!{Y2X_bQcDjSDpb83BAM2-9I7B~dIIbfN_E3;EQ=3AY=q^Dm zQncV2xz0W-mjm8_VaHElK@EC-!ktWFouH=5iBgisaA1U@3bj)VqB)H4VK|{N+2-(JHfiJCYX>+!y8B2Fm z({k0cWxASSs+u_ov64=P?sTYo&rYDDXH?fxvxb>b^|M;q%}uJ?X5}V30@O1vluQ19 z_ER5Rk+tl+2Akd;UJQt1HEy_ADoA_jeuet!0YO{7M+Et4K+vY}8zNGM)1X58C@IM6 z7?0@^Gy_2zq62KcgNW)S%~!UX1LIg~{{L&cVH^pxv&RS87h5Dqhv+b?!UT{rMg#O# z#tHOouVIW{%W|QnHnAUyjkuZ(R@l6M%}>V^I?kADpKlXW%QH2&OfWTY{0N_PLeRc9 zMi3vb*?iSmEU7hC;l7%nHAo*ucCtc$edXLFXlD(Sys;Aj`;iBG;@fw21qcpYFGU6DtNH*Xmdk{4fK0AKi6FGJC#f0@j_)KD&L`tcGuKP_k_u+uZ@Sh<3$bA}GmGrYql`YBOYe}rLwZKP!xrdrur z0ib3zAR%*So7rZjP$|`v$!nA9xOQ4sM|Is)T`iB$29KOE-0_Y!v(GZKhMia4am~e# zu5PJbJTk5!5Jn35E$W1AVWB&zA{r<8tP)wo%Vg0}o(EZ}Ts5eMgW$E9nUDxFyhPP( zs8$YB7)%~lUan?sD~~9DckP11Ea%9&uY)hvUwxUwb}pf|IT$VPqb9AAiAuw>G+8N8 z6Ovlm%$~Fhhg1!#<%uJPW4P+L>rOa{&N2gbFd3Fh-nnA8lL@IrHd6K33HFYag|7^p zP;EZ&_CU5|tx*P)T5w<-hNeoB7VAth{E$^zh&!tb9x@TA^<6WYl=|`BSI?aM#~0G0T^KK!+74^cJ#Nj`srvw<<6E zzM$Kx-86sp4;1hc2-blI9c0tmCMY}Qn=5b(4Vqv z{|sKKb)cXA9B?~>#9fzsZ29S1Tr62*LHahw(?8R{AQudS8<=zg^lz2q zD}8im+_uhWqYUr=fMT#sIo${8zZfe2N&j7)tPfNL^8Z2}6)v8;x|<$fDzHr5?L0g@ zAOmYTwm%3~HQmw+c~!W5LEVM>2|z;BF)jd7U&jQ0%D8~=0et;cR2&d~)H=6#Rr*B( zV9$6xY#V}Z4=>PWem5wViJ&4Bv3xeU=0-BSSJ zgLq4Ssb;S7t=xC1%@8T#c5w$=0*}ik;4@vwq3Am7=yuN-b_|MEpaRpI;Cvp9%i(}% zs}RtlP5ojEwsLfL7&QhevV-Nsj0eq<1@D5yAlgMl5n&O9X|Vqp%RY4oNyRFF7sWtO z#6?E~bm~N|z&YikXC=I0E*8Z$v7PtWfjy*uGFqlA5fnR1Q=q1`;U!~U>|&X_;mk34 zhKqYAO9h_TjRFso_sn|qdUDA33j5IN=@U7M#9uTvV5J{l0zdjRWGKB8J3Uz+|(f(HYHAjk#NQ1jL9! zuha9;i4YYO5J$mewtTo9vVtPTxqXvBInY?m4YD)~h~q$Ax!_EwZpqbZI3OP3;=4xa zULDboazx{;=E*zl0g)CIxiwU0S+taYYlIHHMHZAe8xkWHvSjw;0&`NOTN%Xcr-ivm9Bz1h6 zny%66)ZjF=M6S}>=v4~EuG0F;50<8uJ7@5d0V_2pQVkF7Vq{{!dIm33#3Ft_}G2)yjM)! zd^I{4d6C{M=mM$U&yqhi=!uOq^+sms!NF^^FO?LLY1%(UAAuAQ;Js8WHnK=;BI0?G zj@F^p*@W>;sZ=u3l$xf8pzH;I3P)vOmA?n#aMPBi8 z^%0|sj#w@`5rIzhQ!tSbr|=trz3XA)gH(s7qlZqzSnr3GpT_7Etp6(f@@<&&Cgd6@ zO_{P$>oL!s`$Ftx@?LJr&QNaX8kwntH#$vkYg|R22_$?WFI((Ps;mBgX=;jxe4dv2 zB0W9@Ytx5X>gz7C*}oPKd5d(eNI!)2=dpg8p7eD2T72>A&r(Oc#kZr8Zl0T=_oWh8 z{A0N9vXFPx)*^lID7MGYhmW53!69FY@je$)Lq+<@3s5PVD$*r5``M(QjgmT^@OmO6 z-sp%gHc}rSY5JLvw`8Gz=TflG&)tw(+<*mIXdUgu%{CxCbK8#JowN2@0SO=M^#R!H z6?`{v`CUe5FJ?SwyCTwGaWuckZrbd*cS97n*}$HSL^o`QV`u2{Me=!GI9~_dUxVbO z7s|jzu~fEkS2;SKy+&74sr^v1Sfo!g?rt#d&g0|P1t9ae)DZ7~4AaMp^qVvE1qqxl zUZ9nHsoy&~b@Pi;bSxIXMqg&hucX*B)AZGlZ<_wNNMB2M8@&ts^)Xsm@z<+UH@_KA zm7Vk&{!iU}$6y2}y>=s3q`$h%KQ|De3gWd_T4=Rw*ODsRR%(-Nn7U+pH|>$_UfL(y zBps0LFddieaXJBi>k?^{mF+lLvMtd2WXr!S_d)uoY)gJo;16IEvvuH(Z&YlEF~4Mt zgVERw{mtdnP$YGQLX5QNiKcH()87Fhz);ga;3ro8{wMqZN=5qDvS|E7)4xm6|Cyb+ zfwKtysRw&ATYU!+B2TOXK$*G3l~^PtLwPV-6rR$Fz;;o8z>*(s7WJjAq^m9+Eguv+ z(JTTuX-2FlipGi#>xbCfU@qZdcZ!5pBz#h2ErNo*n((t*0g$hCrXHnm|i`@X6!d0j(RK8a`Hw2l5S1eVl@8los!kPhF(7@ijcCcL%PBB!<=~MKK)m z$2=`T0Eu_#R=NXIH=h{{`4iqLa>{Mu8oi!s7Kf(A;TzGAKje#F5l5QETXFpg?7)M8 zD4Qw*a~?Z-8SK4tke9LDVAp2xFf0l}5RJ{^1U}<`@`|I)B2%(-WLk{fsNVS{3NYNy zg}nR)ue=tyK_MEWlVVgDvV8=;&C^-g=a&0t>2a|ceQr0P|8{y#_POQ$^YjVX=a&1Q zq|36;E%!Nkxz8>4U!u>;KDXTeI(~qWgw0KJD zS&EAzCZPW_^!Tj4^T{T!k9N#2;RO z7iBy{i;&QUo$Tz+nfE#GOwP=ozrTJ1Sc55We021t`blp}YoGj;%5y1uf!uNG{2Uc(N@c!)lX%wI3y3q;Kp>H=-52V;i3A7>>%(TwkwPYfo4kR?qm| z#C16kwWU$vA^EoB6NQd%bM%nHh`l&oU46V-HClA2e;$PpNH>BcwCIK7lE8cr+NK@K zmP_V`PLn)Sf8Dbz3|Fu5lWrRhrFHeWUO$ciK|;QNMYU4B-{xxq=2gh0MJ_>CzIO%I2C`dQ0}U%zLwzhCD9eXj z_~Pck%ya+e`Xnf;1j}62O+JMJ**YJ(mx~=JE+{p9z;saHl6M^@O>uaJ(zL z_pbbfg95AEkMI{PQrP_-wu~WeK)#DjC~RTz1jWl>>J%&u_A8uVq z=X}6rk(Ww~N);x^iv)>V)F>R%WhPu8Gn7lW${nB1g?2dLWg6t73{<@%o=iq^d`ek= z8~x4CS6UNrnFvN?(WJ^CT4hqAYqXBuA|4G-hEb5QoM5x6GZPijL*Z>uQZW67A|R9w^IzUkPhic=6Im%(-`|RxlHTyT__; zTIpHtPB288^%``Bpy}I=`(B1HzbS#S^Q*EAx4u+7Zxc(*~GMtIG z28o~(XLX!G7eiM=)yPxBISPB#v`zndJ?z~G&ZAdH4=ynDG-o(tf4fzG(U*c(G`yvv zwG>!)eOpH#E;0lxhZh*mH;kJ6>$aB=Q(^iUP8ycui3r|Rf%`B(*o|DLxmTuAG{kib zs-%KzVslaWt>u!4${j*dfuna=Gjl-rPoCZgwb{OKc%p z!#g#+w~fKv?Jbb;@C$svFq?dVj~E_foIb8G|l?27Kf`O2bZM(f5T<@B@DC9-<3~{+ae-(qxiFGMiqxGcB za}=}LbSblhT0Q6Rm4>3=gi)o*G!B_6$tq*ItV%e0&U6FU!uj0%!h9}SX6NEZ9}oim zg4WPW?76Hk0#QwuQj$)~3QJw+v|eX=>YZgbHMJs34ZXEzFL($9Pw6>LDO8nGd&N^$ zGQH4GKq$+GsmsL%f7cNR?6y=YGgJHdofV|o;~RKj0^!|%nF=P~ai{JLHLCol`|FQ7a$D7+;JWrBjTd0T_>aUBJK||PoA}xwjpy>>3&$74 zTY?_p_n~D4+YZ_`VA~9v3?#|5p?&G^NcjljeZ~g^f18y^%J9)Cd^>|=NijQzL5oimxJIZx~e9?Ss^Ty`ZaDtBpPPoAsJW(yH z$N4T<;S2#yPeoF?lu&qNOqVhlu1EGea_2aYXH89ap^{o07Yne+0~cxtd5_*)sP&)@HC}ize=e%9#0xj( zimzo}crZ_VtzhsLf5+j%DhiU1%Z6##t_Qui5BGbp8h+wH(WFEnJTC%R=pic)GR)Vx zl-NNqUE8ZG40R2ST?P81rl{~1FV|}^b%`m4HAwH{ZlxUX8MfWq z`a@huNpbS?_U{vkW?z`BxS?f6-*EN3sQFIU*&Zs>w{W~F`=`NPBs+to_fDoY%J&mT za^I?KQr#C`SXEQy8+4CjK)^94+%U%c;)ha|&Us}Dr~V$FH5cA?_7^NiWdTjcKHGad z`(I^x!&*O@E(D3_I~YMN_e-TkVO-5I{WP&CB=atLdm8yB{z!y)5Z#}y(N%%2ER_xg z%>LL&rt(sAn)YO0P%RHU_uED75S80CWtD+%&xu7-w}pRI5;ZHbK<9KYH7;`S)ek+G zpDa%oN5mO>3}rcuKV)=g>f?*8y+^~ny)tY#>h#^uo@~ZRbpFILBs$)PO1o{c;%l~I zD?NNElg^LuV{Zx-9EL76vUNwjEx-8^=V(QVq}1|CC-_?mHD&W!5ytLBA2u@@RT9Q> z5xk@ED^7TkWyJ|qISZjmlqVaglnH+04zh=RgkQyZ`o00KZ%;wtkt+1Zfbr_+Bl+UQ z0k+>A>rBUsjYV?}r=%Kr181gVKe?JMLa*1$T0$f4*>!PStFJpzf09sNh0^E@q; zxxBLnmYcr*boFYk|E>f~ixql>m#wNYJy~kwCc7>jYe_nUIyf?5S*Ly>@l+U_dq?VP z9RTLWnacWtvIs>7Y57AP&h{Dcbq#_pqu_*s8@Sw2vPDmh`9nW)Pd4FdLBXGeCH0r* zFcfC+Xusg2SFr7wr{+65-Pp{>OBG?9PM)97r{om9;MAMK?!5~I9v*`C;{AswcJ~!b zGT&%x5>A3vjHSp+G2O>AlDq$n^NP`!N%izEgie}0Udr!{OnOP@N8JywJ)3jckiV=r zAIy?fcNrQr^K~^JY0~WKh|kScq5ZOLA7*+POIaHo0#m2ExXhm0?<#VZ=$Ug#G@i2T z)Sf$}NrGLS=Y-yWqM|SvSP9zLSvaRuaxJ6M&$r&#-Tk{?9%kpEhxL4t#N{sonv4!M=G<)_BS$lkk!b3IDCH4K>9w(_}s{ z8ddu``20Oov^etWaqOGP*3C+sL}i0^-$R1}G540BRvPNA#!TB~s=UQ$8_)3M}ih#tlN-sc}LQC%Kx^SHHG z|8oV-?{)JR67dyM@{98&y>+wY#<#*y8n4xgB#h;ECrY%I_r-W#892Q7vGc~G{wtig zjXw0drcNP>4zj4bxY4C_vy>^`Y-Rr+Pr?*V^|$zY>>6t8$b4oRv>_=^_+Vk8d`!g5 zY-$%J#Z7eEH0yWLg(Gf;{gb5Gb4L^Z7#|7o&1!BYXOnDC=f&37(Dy_;YU*DaSI4$} zdnSXw0$cR}*#^&pz!P<9Ix+j3>9NqNw+#|^GzK6om9@_?wN)~y&PeA# zqf%KAM72YYcGd{WbE}+khGVVUlmoyn1f_9yf9snxn?~zmZbu&W%K*sAe0FTwP@avv=0APWMrl3v(3BTr=2K50s zLV_s_LTqi823pIlPHNz4?6;r@o;Wlt*%TD~7 zZYWe1GU3lu7)li?gFKog9Px9NMH@s!5^W9E7Fyxu!au9JKafKe0!;GyA83M?YsLHq z)z=Euw;cgF_(706e*KjPNI?hz9AQC#Huz5w3Gcmj3JL)95zq$?oT^+z#KT8+pj0oQ zFEW+mQ5e!lGk{09zHtcvUm>Dll3|e5YK&joh=O{ii+}=hV5p_l2*0-J0;NR$Zm08L zXhndBQ?4$_=r1|>1-;aI&zOTJz_N+B) z9#P&Gv}#H23%q7=tU;GV-|;-tohf=I~hj-MKNe%d3eh&|2-wPA>Ee zMNrvEdyVSI`lvDy^z`gwVkn}LK|GSq#|4JnxoIRO@sx!|eY?;bc>3*K_2AXEE_$R{ zzCVzv3UKiDb5r_h5D*ZZ5Gi+pL@8*f(!iG1x>gdb9^Yv>r}mh(L3OFb;);CzTapwz zf*i|?OK0?9kw_P?-0dFJtLi=zod6Wn?%aD|P%jYTC%iX)k0Vd>Vbm^Cr+C9_<`m40 zM^@Lgu3F}@42za*z}FZG8H@~yghPxY23DilF(fmO%LhdnWy_>0YdUU%{5;eNLSXXl zUnx6g^xx`|70?R~2k4=9*}u3!x!&jn08l8EddKmclPN&pp#^~95+?=Q%XO*?SHv{B znC+V^K--hOSb9;Y5YgB1Ej3fir@e4s?^di<$}xQHuJ$q9?N!Bv!`7I6)5sbU#Lw1u^S7sb(g%zNh3L5MJQsdwRb zgNo>59Bh1jM9C5avMI{R90Kw05r4looJRr#4qh*PUNQS31%o#@dU69Nb{wvnpC=mn zcY`1@hbV^re0-c7@lN8bd570AB1N~=VPVbKaVf?8DYvLWmcdQ+38(I$J+%Pl`B!V> zZq%>Y`%Vt>v8{!@1PO9E2 z9VBjQeL_arEHz!fGJkWBR?n$iC=29KvaHy#Um6^Nh-;kO0n9to04%I0VDXCW&Btps3x;+T81snc7-CrYOO1 zBwrOxlYveR3vF?GBNGFK(sl#BMS^d}9ZAd|AuI~qjv&_lj;$rIO{nGxgiPL|9DKXC z#tSNLxrea`Z|G>h1HZmhBsPlM5D1TDN+yJ5N^b0-)-S?aZIOvmD<@f5T70wrgb#~L za_t)z{ST{ujY^K=AR!<&q5jdEF{PgoCnXw_80e&eDTWr54k^=6hJ}7>BqX-6Sfa*O zwVX*76(t8X%1|D_zS(`{=Gwk?X;d>jj(W%YDu$UVi3$8JI>{GEPR3=|qu_0AlW$|~ z?fv{xK-v#cOEGqPc6)1e7piu!+LzeYWUcE(>7pCF>ncpr8O-(Z6US0#5K{>2aOl)rz?K{}OE1UoTv+&+5Y0HkKiPPx_ zSUMqKtF!#T&Cp4YDQDIn9b;#M?Zx0qqt5TlH)Vr5!Xg@RQo&-HV|Ik@n=3Oamu2lh z49_-ckP&y{-UyE%0O8+5(Hp5n`BHJk0y(H^HQ+mE^&zM2ap4%A~gIc#u*(J)5 zFWKk7)#JdESw8EXxkA^MIWExbt1a0@>sH2q;J4sfvuv@)8Yj~R*bKk6ac2b^px5*s z;S0EVw7#6!03@ahulYnKSqr8O6fq3?}t12hy0(&)WAI^$5R$gvv z@QTmbJl?Dt^=s$=+Cf{OGen!c|6sH&L~`a>NPKsP3{U~V7nSYIyfY5F10YH9u)4{jG`)}f1N+J)`LBYoYP2OG7uKJx2PLrk zT)QlSITdY?d3m;=xZj!6HJ(ntr@OTsF?H7IVL#|XSiL@jJFN!e0Ny9P&*E-|UWWDt z{>&}87BdLGssBIReYp{#Gx&$QHt7G!3L!aliV6-bP-9aYO&He@IvTzqQ6`5*8V1l7 zj;RBnl;-^y~v2}NI+ zyRv85wprnBpSkOu717VWKvYx2T@E4O^Q9TsrgiWM*-U3ePs>E7x%yfcdFZeY{44uN z69!xlWP^EuW?t>AIP)rU@l~4AuvzOoi>lqIw8L?+mEJ4S&zn>+p2A#XzT9ZwRZ4+q zm~E|jq`S;ELjn_c$IUB&{aSFr;Zg6BVl~l9PZ`Q=u$_loMn+rQiUVv{9j%5lM_L+( zo=fA*eCZ=s=NRAK;=A)*BTh!T4lp*K-qo9di~1RfrX3^>-HY&#vrca9;i&=FNC?CL z;-KwYPy<_~f}3;naG%*P5HJP2qbu}5Mj2M)?>lm*1(SDrK4_zgHO@yZF=z*J9xI18 z5{=F=b8U}5tr*<3XBnh6?shxr2>l?LFYL&wztoJ~>N zHvJ8pWBAHvq`zfaPQcFUiDGRr0L@xX3=JqX(^Tpz1GR0mdn@^%aIw`r^AmT2itv2^LM4_W^UDgrqS>F zMf918L|84oF#mQq*L! z&>!lVl~2Q=vbfpt<_`DU-D;n>J`H!XRsHknkuab)&H#9ADq_iOw+^_w?g5l^%e+u+ zh?oj&sTU`Oqw>XsVaem8eA%jSNPV2Yh-qm7b5)(vhLTg1(QU{TD=(cBxan3lB42%e z`HyiWi<_#NP!JF)aQ|c66uu(x!h~oAKL~{hz?336j+|^ulS9bVZ3@W%sRR>CpqI{t zb~>s_?2R%Nww^UJ%>-R1cZ1u?>p!xwp}^Hz?$k1sJl;@O@K&_D2`v2hCHixfA#iS* z#nDtlynj0PA^+vRXU*g9{hdD$dOnI8q{C_=vh);SE3TyMp@HNTk(>f7lBKgNavnvN*|4K_6V&(sTiVPiK40Sb>EmM&r^LVwdWJUm@F-}i0yg8QMUWddZmW)J&?gyYq#0bS3AO_c+c%_lCemS1~~WLa!>+C>l}I5AS5ra>86N z`IK^Ng`*ayum9rwCR{DhQ=hvP&rYCj1En1mIeWF1KNtOH;ACV?m!bG~@GEH0>aZH$ za*8sc1Cmj~|B~hD6;c?m(gz8e|3rJTitJ|TlxL#c&zhHqmMfh^H^txJ0cr2&e&s14 z(7R^4j5SiVS$?jqA-oD~tD7D19K#0mc2#xL;??uGEMCPKZT|u`&vY(vjH20!tZ>kj zd=U&uY)mop$M2`Qw61h~#?J|{9VWqlDeS}1`bAp;+iLDr5KDGGET2Sf5u+P!={UmE zBp{h)mlf#EkaJua@h)3a(;SPFfI3+3V< zDekSd)8r^4K~*u_lWVUy%8iX!U>4svfQb|u$1_|${+P)DM8vA>NhXSa$bnV`6>v0M z@70rW&6j<0WJI_Spa8vr<%3K3KFahU)hsPyY6}C-u2CSj)#8sd@fRuN#k$wH8Y1CK zBBz=GxoCu@Qmx6CA*=fj&8+XWC&_nw`DjT(tu&yK>bpsIw#cAiJOSA2?=lNa*L3Aa z4D|vt*eiy2QHi7Ucg2`mn{f{cEF+O(i+L%7KCA_)6)ND^@g?}7P{Ly?Ss$XoV_b4O zM!;AzR}%2Xk7aA8dT{KHZ1mPHBzylFv$}ahv8m$;XepbuvP~~_puD&$Y~L;tfaJibfB*SWC z*(ZJ7);`c5sj-&vSbyvMZdWu+_K=f3fuYF0G!8^S%6eL|-hNLr<#c?YynLL)R&!$Q zWTbP_kgx}R?Hpen8~`AfdW~VQsI91_G0G>z=gWwMTV`Y+g@DXZDHkJ`@lQ+JI<2p(#48RjPr zbnYy^9C6!&o=z}=(MF; zE5nC_*BY;~gOU3+u^wjCWz`Sg7s9ero~sds8{StZw1j;hEdQK}I()6MM(R(*VR8Ede!NZxC)Y&G=G$whxxiXV?JTemD6@ysi~^qCQG`UFaY%z zB!ab{_Otdr&XWF}sDDQe?4}Ogl-I{==aXAna|)!&IH4_sCqpLc@O< ziJGO-OiuVR=tV$_*fAP8dJ62oT4UMR${9NqI;4)J9*mLQf=9Atq>+`qd%U6O4}*z9 zX~XzNdWGVvCKAC3L)hy{uL<}C9I*UIF4=w zLLt?jz)0+mW#&Gn1D~iSbulJxaCLw+)x5;Cg0H{=vFK24)HLFgkF*(w=j$-Hpv$SH$qlmCws_|Dgk$AEG_31BM?)Q4dq|=EHV-k@ zAFppMXXxdu{bl{9Rtq-pE6@9_lBUVfUyjZk*5g?pFm2PS!7I#=P&P%2UkW(Tb5zbs zb9uD&2>42{J9+SiXv1W9=ty1)cYwa%D`sm8o;g$Wm5t4-Fd`~CNB+8*Y^dwg<85M}zjlEOeif~q zos6Yr%nrx#+DnSN7XmB^L?#6JE(rHA z;aOD@PYAQoTjZK;sQ@+c(pTdTNuY&F8^z!K<2{T20DPWO;5FOuq@OC%n6bZAexswS z9;e6{!lS{X5&ql83{N0QXqRslc!j)sfSjSw-Go2VYE=8eWycEqo$>yR_M7~RigxYDK{J+N>F--=v2I{}q^*ea`6v2^t08s< zd(IfOp_&^B?mmSB0LG;LkNfRr0!Zu0*DRUKj=(=ZF)8{YGr?k~(3)x;H8Y`XByk~S zv|+K%EHiTZLb!oBB%|jFQBmyyyQ@3an+vIhuq2QKM!&{0TN;+q5(%k+bJw-{4LhX( zT~y;SU45QO%v>{3ojLE!;@F~Cr^G9lNui|*kKPxA#c3IXWWu&SwqjqG;837G!#`co z=w6)_W40pl_@y)?Tr^QvFg$UwCUjbtLNsLB<-aCx`H1XL@!wdL@&9AhCZz&U<3brz z4C^llQb7TIQt7Lvp_su&nPHh>m}UqFS^-Kj1UT;Lql@F+Zs{F^Mv1!5`6_{&A&E)) zGlC=ENvxB4Tglp|?;-ET@Ob)0R5a)d-k8wP$-%+Ov`p*ICt)zbc}ulR4ZRktz@PM) zz?xHgw+kxJgqaT~4rd0!QOfJrkX@(>;=VICf3{j}km zONL_(giC}2Weaw_U8lJ06gPq}+G2@rmRNdX-Q3`nx*ba<@c zV%x8LY_04qeD9THm8xet1f%olCOb!PLQWoQiVeR9I;W|2IO*=bu@)gy6S@|>JF8=P zVvc}CW%UN{$vXZw%q3xXA zT2Ucb;d&DdtaXQN?(6L_2EQ-g-uCU|i~h*_sb}sdp5F(O8_5IR;Lq5&045Fr)KD-b z0OaypT*qs*G^e}aG^QysgjfrT5cVF^`Dz})ZY4EFxMTWpHo#W)L^mx<^cj5lS6a>P zuR3`}{A@?^%3|YQ#*HxBy#m=^3t^i~;e*q4*($=qevbD?t!A`bNHLRJl5A}$<`?sO z#;-2ZZ}gM(w_BOlk!$nL1A}qjtTIbl^ZNz}hbSqu#=l`8kAH;b(AtU) ze7y%u#9?v)$HqtTX?TOo?L86|PclE=RH`Cc&QC=Z`;Oa8C!~Acmp{11pDya~%qY`XEq@3rN91-8K?!hC%*VR{nvRzZFtf&dgxU1D zlBl(;ZEU-!)jRhuqk7Osu~A+F%h0r#u!?aQXuM-L=1qp%+YL6IL@C=IoOGm!)R(+K*l_8GAFvONs^N&5_*jIZ*%?}Noj$@^hb33^e}BjbST`=&KJPdl zXh9KYlWS>a(^fKt9!R^aD(!Z`c0Hdky>19Y zF6ry-H1XmU$}X~^jS|n(L|;k4eFB= zcA;nZQS%f7XGAG67&%23U?md>>O`npF|NrR6GvHd3oW|8`A7-A$`ohlcq({2glYHC z9VX@ohWj!DqbEx587lz9-PLRgJJQ{+kJg(Wu?-Ji=(Y!(F=xYr0%(hi{6|AGdI*8e z=%i4?H?OHU|{{Sa1EGYfY5W$oLhQG$Rkijy6WS+NNK(dXthd zBEe{cTPLn|S4amhH4+Wyvc7%BldUB0ZGg4_cgHM*KoS5!DxbUj1_Oi3^Ke)2PLtKs z+usBElZJ`iH^7&#VV7S7)mb%swhgl-w;J=bgOW-mT-&);qO?aWN=OW2Q^+lp2bNck zS2_0zCj$Yfou_;_+H-(71wS;iF{&MBuk_&ntYM_4PUi7hqnE@+2)7N3rrVTAQDvQ6 zTeElY;vLTS5QU8uE2`?I`AJEhB&L-!9s@w7_6x?^368g@AB0Vs?U0+V4al~h)R-Qk z3ti;CaZ_=}{#Nmq8`h4*9pK(A9_5)JR&Lmt8^#XAWBp2k2#}r{OPhkUtTT!JU6&BX zab`EB95Nu@c{k)x{(LK##t17__jlO{xr*>d^WBY?NN_((>yW8<4Q5@R{uQd=;^$uf zc(QiCJc^cV3SXg(1)CEl?e;GjkAc7_u0^J}$sm4HZ}%1!RW6D2q?vk=q2ZJj1?_yI z@hMAg8Cds)NdOKU;66$KW@#SCMxiH&fG*SLhAO(={8u#`-WC9K$?xE_zwQJO&ncU zZxKrdi3)nyT=RQaTi-P7iUvXI{$v_D2@S0`+*Ma z+Vud1INZ~aKFqyLVj-|dyjtqc-L6lQ$FZpac>cv=ycW*ODr&5r7VjEvlAZY9ZXq-M zB%3k##=}l0@{C`nNOh^w3fn0BU>x7U30UA@&fC7A)5+ctV{jEU z@xs+#R78r)DN9*H9@=MI%2p~i6#k2FVLo){7oo+ekK|)m#METfA93mBAeMe9@K=no zXzh_dw*2{Hjx4|(F>6RU9sB&qFp!+0W##^gHSpD>RmMi~Xe+#2AdVe5Prq0jOO&3N zBbF$mUZ&oLg>ghwbBj%Xku5H#w;|H=N0Jx~^_C)6Ig!98jgGZ}ZK|f+0b?y}yePA%_4BOHau;X2?92kYeFPzTm zm(C{oPyI{zq!X}tcnR?WSIMN1w}Skk54@lb*uM`r%FC(>F7r1bbBjgkqG164hr5q> zdasmtHeH|&rVL)tC^YY|E_ERnj#Z95LU1C3F8X^kIwK4QRb`y*S)(8oW6pL*H@HNb z)z5F+qbGH0$9Gd3sV#rQ_@!L5xWAKL0rPa<=DTn)62JX6*0X9IFe&qgj&K z?}?PCzcP3D_0tFvaj3&->%KmQ?2KAUC-K${ueIpP%yiHx+bifEJ&QN^29Q8Q;#il)}{(0pv_GbV**ux;%MO z&wulG^8cnX93Lb|m;%5ddd*mFMoTcEj{0Su6Z_RHi_!IE&DLdu$X=!vV{Mq|7R1qJ>Gbh){7;VHu=b53kqphD1CqF+|l0{^k-(GgfnVmxFu(@BZ zV0>e&*oIs3=I~jxsmYR~NC{F}@U|77rMB?JN?FLk&iBSmUuV~Dq0t%^s5LJSa5}q9 zCX)2d?oi`rh_&r3HRx;cgEw@9D<1$s2>?{;=A+_@SO_VHr{Mb)2}@)JZ&X&E#~=IysPiAE0z<)0sNf>1GO%|YB{$5Np5$Z5xFV8*+%SmI!{$!~mVk2i zulpRi3W_Thn}Tow;c_of(RO@>oR{cL!>*Ry8bQ*4F8R{=gXo;{%yMzQkAmSlm6Uk} zd|SaS_e;TSjh%s3Fos*+$e0;Jr3aOCxS%F6j>L_(W7~9Hh_5ath~iw9uSrmDCoiKFuNKvZo3k`{s2>Fky5yX|JPXiWy;9D?i%Bk(a1-}9wS|x_Xi*kj2};Jr)l*{9LVp^ zw9*TCGW*cl-_=jM_F|TwuS50`2Rphte_<49c@3p+ur?nZh(SQ52;pEW{WHfFgPL|` zQ%={{f@xk<(Q!AKOGOwNio}1<7MS9vCX-~P%#XpD0T`M*6HzfbL@C$ArY2yzju ze+y4&22eN95H-#{m4vW8)p{+;) zwDPiZJ9y;Uw0o`#n9l|1FP*3blpD6j?O;#Rtq*0p$Cz{6lSD*#_;C`d16W_v;9|v; zv!ob@TY7Vs}))4t(V+I)8aZomfN1K!*q$ zA_Q7IH1%zQ2(z(!_6k|glw9sz#f-iX|*rR*)| ztG=r|A7u#;HHcN{^jw^FwpCY+4dKMf5KWt-D}2Sk*tceJQ&We~7Xy-m1tEdt5wUv; zKr#bP-_K0{DzpLd+gnNWVg#)|T5$;Qcl8EC8lTF1hQLUztSiDe4E;I$`%+NKFR=n39PLSJHv&u(EPdM)f>ha@TGaM~RAbw~9$yz<`R zJv3Sv$cle;0?%<`vg_T?hyQS-ORfCn#Yy9FI^z-~^|X+BY^21v-9us5qQ|nOXOooC zZ*Bq7)KeGPW5`AeIRG4gg?0~9_*LqvMFjA0x&eQ($8YRYRHI95 zHkLBeibPQGHI1M-p5l0kRGdEf>jve5xy6AR1#dM~kV6vBu0PrGzE3toeE#xm zu=MtlH7E=8qeLv8%~0mD6tU*<4X)q%oU(#L5?Pal=E+dgUFF8bk{gK3snCPM*+D=T zc9lWsAqT}YwmfAL@V3Ns@_>Gez3N|PtUA5vxenBM;0`hW@Zjjf{S7NTRH*WtSjS|h z1ROZowrWoWno2Q6x4eut$S5R``*l~9pG6?02qS%|MyBH^-#>g4Fd>V)^g1FdY_*Sn zm4KdR7U=;D8(s&Ub(q2U8Rd(%I z?0v1zIJP}U=?1>Q8k<`@LFk;y5}78(A4ZXqmGQ0_7NrX2p2wEHpPecM{4k-Pj!?PN zW1dHZIWP3!l3bG{<7_yS)&WY}*!Dx`d&jWV)Vod=ASHdGdj|=?ZbNB<3h;UmET!mK zDxvE8|F@nFiMd4!`}aJlrxbA$r|1X>0;lAMzG4YmI4rHcRj$;Ypu?c@B-Cw^$UE8X63ZAf0mt&Q zy~4n552vLQdP{GDqSLL`1M#IkU7~Y)?y!l4!Vrr&W#>%A)Rv5o~2ZUj=LX*GR zQFtk9n>Gu+JSf_I4&wd(rNtw7WbiXfUelXJ_1AiDO;L0i1JN=x!RH}yt!7iSKC+c2 zgRZ1e;VMOD)*xNk? zkT)HcC8r?ulef=RMd$JtLw-t#aI``~m)nW@9Jpb=5p@#%h;86qg`Dpj320#`AKgGq%ChgU;Ny-eAy zAz&1Y5s_mDlvCzI+`#W*eQ?X?=+u(WBkE=HR2fhgpE+bfu>r;B5zVqUJ^;uwaKoYT z_puRMn6m@-Gajg>pak==g<*D6>EYSwK>j`IPc-F5H=}d z)uw2%6{o2K&=H5DE*_WBv9hvZ6@mgT9Mvd6Z5kf%Vw|&VpWiR^!fnhW0=Z$ju?ySm z{(RtJ(Sh}QD2r>g{3sMBwm-H+7Zf&qs%DPkS9E=sRLY_DWUKPx>m|XY6|y) zXO|R;c%*qmEV_lR-+UwB=Js=*A6J|xiVRFMvD|FcMSaOsDOGyxAlJxhUeU5W(zv*` zCNu<8Bz7Yb$>KI-IB*8tHpe4AFH$NDri3eQa!){xc%WN%7Obt-y6bJ77c45YxpG27k`w;2g1afr1&yaeL6VJ zO_;=HA|<`iD-96p&MF^id(fLTR~;oSKDh;oo2fYN&PdlW-ssIf zQ^GyOG(K6@pxsyo*j!Ol{kjf_65Az7YT2N!w^$!wc<%i*h!3@ewm;B_H^PqR#UJGMguO%A$!i8a6S6s67|z}yS3l})Sb;CiiPgTL-uUd%h>%In z&2C^0$}Q0Ve;ncHw+Bqgyz2HHVVeI-Aj0?$YTeOu`($eiGNaie2RbI|%-yPWmSx!3 zHKVRDlnmZcw}fYWEfOUHJ-L5b)FI3h^GrVUz}OvChn9Wwsg1|hcs&r&-Vp=R;TRi@ zz0tqpAh}dpEMWyjhx7g+O|WjH_9xwZi0G2JHrJ~aT+xuZ*%PikJyYHusndOlWIdf3 zt4OLYVJ*lT$bUDzk(i&%9|cG7O(vFIFamrUMU+h%INu`!QQeY@;~l++7#CsCc3+VzA)c6_`+jHlaud(lFpWkhi zrBR54UrUy=5k^A;E3f@-R%+y*=1hEEAW&{K_oilM{@hiVQmo|u;NwGF=K6)KnUAwE zu70AIdCbo4Jdx-*e5lx^IwLz{l-9Lp3uK5Z*)EhF)Wj*O7v6t0eZ2e~+3Nk?9;A;y z^gU<7wLoJMBM)6Kk2;oRT;O`-)z_tk7sWy!x6?X3`2s+*)M=~m{sYup_ zdR;Nwc2d*Qo!h47bR~@=Z7HUFuT1@LD{IpQL>U}k zy(F@_@Gl(#3rYA1+HqbvgtrL?u0K1*5(WG2i$y-C|!Qv;*4o4lPnl447`49&@0`ghufkx$E@* z-|{(o9hb@fXEoCN&ua7$Qv|9T{mV9E2~&oT{2)UdxoLPRv(3>Yr? z&Ii3~N#HNkN$ucpqlV*fJ5ddN{WO{&(YQ!AiEK;dfL|${q|bCh<3PBNt<6*UJdK$t z^L|!N6Kwus+upyga(SKQZq+v^E!Jc=a-ZlCslyE991KzT#K{w#I0td9Z~8+Cwx@xa z;b?U2i@^wIWs6kvvPwk5($d)>!CC^UQPe52#66rGQ{z3Vo!r%&bOgJcew<2toEk)_ z&^Rwgs<8SrZns^{D!?KyHp)f}Wm%h2NYokwi(vWCVn2_I_8zO4zML9NI xt3G?g z4&W%tBqXPbR`Dfgu>2G0TRV&4bOw6_Oz;EyI?i0{oODJ_4RFbbY2RF|&r|=*#sMY0 z^C#;!#u8O8tq#gKuO^NKL!6Empu_>FK3#2qJJ>Gg2Xel_lYOijF0X4d*)|59)BO|T zbURajp;K0G60wrdr5zug$aA=!F{BTlUpz*+?~5|qnzM^2-)J~`dY|bTblUUB2E0zZ zTVU9xOlwKgGs`C~SzmLe70MsFd5R%^#gG5Fe3Ew+Y@7c|KK8HV)<8aT1VdaD@53hs zK3VM$cX*5nGAbswuZ9B6S5JK1%{5s~{ABm!0TL{o--m-y_e#*5;Gks<%U4#kR=7EL zfdL<|duXWGB1Xo2BtemLawd&4`z5*w2gbEEKX_nZG}cRNIdHbz1`hQ=nDsa0xI&=n84=S@gGA2DHTxQF0jjouQnFsD*`p#;*Xg)EAP?cklFQb>(sA} zqNk`Su_t$9u&Lr6nlrh_xaFqd&PVrLKB?HbeLk9Nmy1i)h$6BN$+5&R?!ns!q3|_` zIU3m-`iV13Iwu;dUqV*uj0CvF&z5e_ryG5NqxHN$QUepAY&@Sf0-)FUqLKGPE?Tuybs0 zflp2+u!wi|uhII!bB}KtElo6FEaxecon!{PqX04^gX-k2woe$JhG1$>RKotzO>vVX zf~U5Pf+n937U@14ClFNVgE3AL>-48h;3<)wwUITF_%(1WH zt`@dMMY1U6RpwZhRTz1f@oPD?J~KglvhXauZl_U5!YyO@N!e(v>YEx#ue9$_$}Klc zs*8wd-HSapLJ!kDq;u0txcz@oOi2^~qFdeV`n?vl9v&LL=}oqoP8OqVAI@`b-wuJV z#+?@iA-7*ULLx$N1cjJ#h|QcqZoFJLn_I{uu?x*pMmvmx_kgMF0znV&_ztnBr@!8p zUC?2~#v)1@;PrS~$vt15qCoU8teD&L%Pq%N$EZFxAGBC8hc`FVXvTO(Jo_M1oy+eA z^_7k=J!_a^M?dmq@x)*>Kzt@6Y;3xou%6JGW&y8B> z%%tcqpf79fPUvikJUbYLNldGFu*~+sGnDd&gPW10*$Bj5mAH|82V>xVA~7jHbf_8) zkrU#%C=m-jZC@4f5pIxXQAfE2o*puTv=@LPB{+ngnBZA~wL)Sn@ezgnuokGZkd2Xp+j2&bAixl>UdhtWT=nGFR8N_Zz(q8aI!vtcZ;N^| z_PMlgO?9N@79w`#!HdJ_Bwwt`%JX-w>Wr?i5{#LOhtl6{jm^1C9Oaw(Ts4^QQJGmt z?>sWdDdptyT>&Adp*tjGx)@ko6sv(%+W1Jaia(KUfv12MZ&K9|@NE-I_&1qmt`**Am7|)DZk7<}0lEb(bp|OCIpv*7ETM(?Lz-@t>PU0~)D1BEIs_YYB&Z7Oh6Mb&2M1vC4_z5qw(S9VpM z(nw04WrEHJBmi=6!AM5Kz2u>-^?$<^=|x*Z=SbD^Quu;#a7yW-gd-TEj7IIvbGYQY zQ8Vby5K2EmJ!6U^gGiQ|bZ3v_9_J&t&?p*X@#E{>6R_(qR}F=yq_Ior>zBGeUfW8H~Sw?Z6`>8E+J9iP_v1=!S{< z7cL7;5UE+f;AR)G+fPuRb7yyAU!d8}<3ABR2{@VN{c~_q!3x1*kqkaMK6X6r?3z1<@E1h5xo+)q%j zQeUw}SQ(M@cv>Ykax~-it9ui1yu#%7$Qpo;opj*sy1)IXM>x}9{$ZmYTBW#%;qVqi zhZibvl2%4P>LkNfDwL&iLfuZ3WSr5XiNO嵍U=YZ;=-zZAgMtk6W|B6MQ%mLGOi!VUaes}cKdr_muGq32|u=mv-jYNgoNsOp@ zB;K72fu=13R*PL^aT$!#sVQGJ?f~HB!%Ib9tE@^1WK1dYJ7R5DOp{+4-+E-}$8FT@ zyL1$dsV3Yi*RoD%Pd%6nB|H~}^YKF^EW{ZID$iO!8{-Fgd>S0m`RThtkJ*neX0c2s zx0$b4PKdu^^5G{7j1+45W96FJBE!-2{^S&n0M8pY4s8ecvB0N^Bls;;h>z)k{)uRawEo87!8`(;s^s+}iCoVW8wG*9`O#GKq~&;Izsux{YH0QP!kVr1Q!N&Nz2#>9mxer6DN6DN zGDzbyF)74=YyIaGP6tt1vI`p*!QDtLNG-MsPdc6Ecg7u)wU1|@&WKa21K;=A+r8_5 z&npWTw=nKJZ|Gaz$y7WU5VP}(G$XJJs>QyrHCo$Gq|^xaitx0K#@)d7=JWe6gRp)m zvty%aMlK3*m#oUd3*z|xQF#iTfiuLjJ7Mzzv%|NHHnaw^^{jFkm<$n$rSA=Rvr2(O zs>LK+>Tubw@yD|{m<4WvGSt@qwCkOmVH}d4b!!o^ITEh-K@1ASIGXnZ+ztGnSm!K~ zl5KyY-(mlgA4rd?Ww?I~5&M6Jq$#ojbrF0GwXpxXaHhNn5X*Cxg%@EJ6a{-GX9crl z(l|PGg)@aIFLJc#npiv2G~|7A@x4*PNsLBfh(@A-ucULvhH*+$rc087sqQEYi7qbQ z?_;fOCA-`DW4$QHu^P0&y1V5w+k3L*G533}^W^z1;~I$elK~dA`Et3w=`T+2n5FDrp(1XtkIn(K3nBNyxff9Qa@)? z%r8vJ9TxBDw=#)cqqC^rUB)D;J8m5X2AnN;L>^j?vhlYgb4qNPISaPn-WixPS}-qg zk=FN1!&tym)rREl6UH&w=$fwsvwTNam-I+To1OylH}9TSBePl`TOdh?LX6%TN#@S2 zX@VwxvgEsCFLNwg*C~QFsdlxb#4>UnDZxEXR*u(vMxX%?-yq`oi>n}brS&$L|; zLnoEiNEBQ-8ta?fIn7&P+!)fK(MIh6w2C?Et^yMTEbU%v6d5e}G@EMsDUisl1rZrV z`Q%*w#seI%;fZTQGDdd5rz5f4sOICMFL3}7jUOru7xiBu?BWaMY8|X~`DR|Gcp@?B zBa=iqwyq8>#B!NeN8M1?FZB_0_B_gs6BPV(%(Wm8wU_iUcB^fWgd%l(5%tT17ckZ4 z<;zc`A`J*^2nBL9p14lH0qBg;+tH&Id!2{`0_iT;&nzh7BU_!oTMHvJv0SpgRnC?V zQdOPCB=Dm})O%9k#{Jk)V{#R~GLVO)_heQ}3C3TG| zS_XXN+kyg{>!a9?3!%HrH+lI&-_P{;AN`Y55{=2)^J8oo6lsO$X(g&o*moCWJnC{= zGwBDgNsqo6{yoOPspolVo|~W8=ErGe_}j#Y`&v8p+Y&#^V4N|-)JZsJ*b*`corF_L zLZR7D?+{dekFL84kSLFR(j5QZLCd)*rqDf08O}UAFCmoH5Mte`C7W_RSA#J?#0n;A z$mFGu;yKrhTgrB@`?l@fA?>Gmh)*#lqP?5w6n6@xXUs7UMEMDc-SS(aJ}2QwYW@$g za}Awh4fzktI8`i;;>TP)YPah03B3C32sJ|`Y@Jgd2Hp-3xi?eG9CTr&XhTugGGB?e z|B0^?#M-`>=6X4ipu{7xXB__)^AVatw})zt>oi0$ttymFMqiz(H3?l2Qy8&<27x z+M!q*J`4KJHZ60GC7t>k;*^qayQ&-Kk-x>CCfq(5rXH>L+GlmSUprYNbZpx;I(G8+^M3zPjj_hsXZxUP zuA0|W{mWH3HIR@vKKIu^kvwhQ9r2(5h@gKk+0gVDf<>%{;?D=He$MTQtL?y?QGa_`0J}F>T%-%%h~vZ*z=b|*<5}j2jv=B~`7-~&DU@q-mHO3Msxi|a z+@LHR>!iLVD|3rYZLaH!Girz9?=(0x-2lqN9bc9#nmQXL{3-4SlfJJ&I zU+IB_YDoFFUBTy3td2G9DAo*jt&QT|=jf{x(92#KoJMj~vt$ts_NgUlD35D&G&|mO zv+gwY(SvKpBAa+OledW_ro(k<^e6Uu=0Z~iNRNLz6{iggne3B9s9(But{CK(Sw%~D z3IPL+zurXdn&S=CL)#Lqru@_rz(sfX7P?qeR_=Q~Yw3C^K2jj6RyfyOblBh=+OajV zZ(eSGZwME!rs0oO5@3>+U+ptUk;QVIYda?(lLo6UFtdXHIggVh@CW2+{u)f51`9fE zC&+d1DdJMQTkEW5MP&@$QO8+PA}3u~L6XoLXUX)> zn6A0BdsvtlV3G7^px-P3a1@JwsJZD1bUh7OZv>K+m?L-C&gl%KCxe(@7B2H5@|hJy zFPU8IKq1|JJOKSj84^muaA~S1W_!zEduI`3i4s)GKwzQ|X*uEVxegulFqBK~R1>t%rTGqgY^?8O&aSSEP4{KvEK>r@%@ z2zib>Y$CqD{A1dVRh4 za=PPtt}W_yhvY{-(?5}}_*%mr_Cr_zt#MX;qA_s{T-n$Ppk;V_ggTv2T5jQsJbV2j z{O-b7?Bc{$Tjq0weZNHS+>P!rb|=3@Kl*iq$;2=H1z#Z+;Q z`oXy<$+1NN@W2t08b#hS)>ncR=^)1w^$6wbf<0^eYtLaWILs<#6#HmZwgcZqGA@vp z@?t@R;{h2YU$CnCX3Bj z%uHo)vApBwbC4z=a4U+!$&ni8GbO z!y8+xhcJadT#9=F#PmKIYcGB>v-xbxH*Ifj0(Q{K zoBdh1rAn_Fez#hA+3!&(fH^NOYk)ieDEZ{{W+_r6v<$snvUNtllOxmrhy@5wELelC zl{t=v|PQa*DnQM3ex>m#f-U;N3^u`Zpq!}*{b?pB%!OEyZj3j$a z9RQ@S&T_|3Z!V=ecaHpSB-SJjiuHwG5y!{=YnHRs) z&BL=xOhW(UaCKUaU)yera$PGurH!O`TmWmbqLhFMNeeSMvmx2Xp@V(dDcN^a^QC_8 ziE!Ng=78xN#|^@Bb`pujb1^~&{VH`Bh{Mm&XZJjpw+EOQozXdzN zsBkCGl|g_4={mX$jyO%~)xZT4Y08L}rh!IRrF)KmHC~l(j`&n@Th_Yj&>CJ8ExKog9a}K*KJf~KbL@1txe4Trm0tZu%TGmy|jWOI4xGKnw1 zuM}GiGLZD+8vD;sXo*ioX>>LF$l!UiGiD*zgjO;{^+NY-T(pQ#b{!2bS>HKvqhIulB|1Y7o) z2$z*~Aze{6z;a=1>3SUlG&BYLG! znOp^X#x^XDjA-N$#fn%Dy2^E+9Y+maYdTQH3Oi}Boe7rE1W;KH6U%lP zTJoW%D&n70S%J~o$QhC07HGab<5-F-t~+B2D)Kk4(wG>AIVKd+hSn-Yp4O5r$>+0I z+NNK5q?y-QlMx7+wqIZ3a?*+(yk8EQL*@J;kYntaUBz2FX})89ilS(`A~{3ZN=2x` ztC2=;vKU4J43fdR-gOm998eoeZjvEq;>vQhz+hcE&Aq&cF-gHQNKKmIq!dM@sIW;_ zXty7{Uzp#r0#gWS;{a8LVbu(}qq3RAjI5QBp0IMJwnuCBI5bE?qGDCCk3Y*R{#W9gK`(lD3KXAK;&vC@+57Qeg zna|k>Gcz^#iNg{U1i)o0Jq8;)Cf2A#stEO$Qzu6b6NN(4y{RuoKI1KN4}a5Q+Bv)L=5y$>!Xnnn1)YL!WEh>nbNldr%U6mhbXBFe-t_AGRDX=eRvZyyeRz6AILCQLkq26( zf53r@h4+-3D#cwkEk_R3$$=7h&bPU93SWMCRY?zO@_+E>+~hA7#)FSKh|7$pA^o{2Z7u?`~|Fvwzm!TOI1w8c88_Oju82$ zI&*K!e91=c*3FB^eruo=_o7~%JS0d5kpMAhoW`9!f_tMezkfm2?s}GM7mfZ+fU;1` zSY&ul+$+uOjb4bKaW8(j(NkJhX4V0u%)p+FCU{MoCNzIAHi2~)mSg-;N*!!4D5`@^ z`Qlid_yOP@zJ(_5u!nj z*BGkbzpI$miIu|^W)J3M8e1U)lrX=xXq@^4D*-bmuZYmW2I!eG;|hMjEW0J2;$^#$ z3t9wBN!YFp=hxzOQT~*j4JwZSS(eerh4`4^qQdWQf?-JDzmPe(m^Be9thZ8VngFRg zN+M9HA5j1X*>~V)0#l6~W)Brk#_74kFAQ(6;^9DVy+_1O1Iv><kaT0{P+rMNNC5l`a_P4EhWQdkFf(_adEK^GWA!cQmhrKG;bieE2>s`^Hl!#sH)1 z_HG_sOZ9ti-~cD%KjjM%q4~6UX~4U_B*sTIA@jzu!eD$_lio3k{^lY5PPF=2Au0Q0U(V8dT+st&jd#SHD^9SrCzL?Ma&QhT5{7_moH!* zggcE@z0_;Io(et>wSzhy&Fo$6*nk>*j=Pinkq&j%nU+SUlbV&^nJ>}$?I&g>KvaIX z4wwzdVCxMQ)>{y`LDdPoCkHtgMCNH$Zx0eNtXnh9|HxCJS%!~$d|1Cc8CBGvhojqD zi5k$2&?E{EFRMR5Y2PIvzXF*o81N6n7&jQ?mR?K z2k|gKAD~5vbbpa7=<$&@sHYMo^s!j+8;Ld+;vdL+_DHhGNMD%Px;M^5pKlFw?8$Qa zTfi;1P!Yy(RVodbsuT5l66-!v@J^cHc*vR$6g4Sqxf;`+J~(1pmHfo;ulhB4{YyD#!~;NtZ|FSTtcjlz zwQGb9Jf#+09n2*y7seCASAn(o32pBZDz!U)ivcsj$wqCm(#Fx+e53>BpVuvD6s_30 zQd-68FTh*>Z|!~)YE5YGh8^?bE+ zWl+|DH|IS7pX)vp+d7KcZSvTjeF@e*EDSP=YA? z#97>m0JWcoNnx@pX26ym&Q+%&yI+#NNtp^wMei+m0QXGjbz#x-fg<)KZ(1?>ik6GhGq$QJ`7vo4vk$WMe{IA!af>T zvKxwHB4x{5_f&0#NJs{bdw3e_VmD|OoL;pm^%cJ6Hv-BV!Qv?Wefb{Sj6G`(H`yqV zk{0>Nl9Pw-7nTY~DVLC97SuC6+;?OUvGHjG839nisRgtk15O(TmVn)Eg~{1aQ^}9z zs7&h9Ieh7H&k-n-#Chqb7cBkCKR*J09|{HY zXt$&JS^hA7Y6QHKWOF86po@xeXF@4lfu7%fBIe{-z!Bnp6Z%)*%UGHo<%pPU$>P?b zta{z;Bmk}g7LP-*u1$G-62e^lwF(NoOg%WJ{+qBCu};VY(A!Z~~u!%-9gIqui`!G~9tG#B+w3*rrjA zaj(PTv8Udb`;cvvoPwW?Gsdkzy*1AjAq z=tackkd9&BRk{yy*V2CMNY9i{B6kHaXpB*?Q7you!N+O`^|s3KofJ42IBV$tI)^A% z(DIuVL}^#I4GX7&4h!_j{-$pVXcW~jr&XgeprRsqrz?u-NorJg+G8%!)~tmHEFM;n z`X>{jW{|jBXvq2IDpxH*KOQ@pX;!oE(vA+Un-=4KbuPcd`dCd!8xb3xs4N|5oUK1d z$!OK+l0Vjq4+>HO;w;&iw<*9nTfqWA522zdGMKrCb1ZYjfSXrofpaEO;KevFh7~It zk)@bZdCdvLK(q~vp=1)GLzyyG!MlT+a>!HT+ zw_(FQJ*8m7jM3x_W#ZelRCZ?|$@sEv{Owq;vG1{ACfGpZR|2|KenuGuzSt@lK>(m~ zlMUy{K*k*=K24aNIWPy$y%W=Z#_ zNf{10oX3~48PEnp(Kz@>orVLXA%IrqUFzl+3YLA;qN8i1Hi5J~J{r%kx{YCn&N0xj z73TYV!tf-Rc|K>PO>a zHe>Q5e|(_rIsq96to$~(h3kUfe(^U@Z?kh<&W}6(omF}Kza$A^!14U+dh%|(!uiM1 z9KCZ8=PfeiAH3>By7P>@;Q+;;*0B)s52HPmA4bF!a=68eKpOeE|KyS1PmCvLv}NP4 ztmqS7162ew^C5VA_>Pb8+hJd~V_(`00&XU;{`KNO>Oha!u+0NT0wlY+WAh4N3r<8| zvLtU{iGE;9RooK&?);K{-BISiud66)>k`L7kDH1DU$YiMm&aASFTE?h-*a5=t5)}DU@dFyuWXF` z3=nv+Y}z3wYA)4VE_cZ2;ZwzlJw}6zp+}Rrb2`%G!NguJ{L8TzGwkHkOo4Z4hT?@6Zd_ zHnv&i<{thI;4!YRay&L`L>{Qwp=ZX6w-AnJY1;#KY!Kgu6P9ln3Mk!gc?R8#jgGCX zUj~3LK@mJKS44ozj`nFjY6d8)az#TEaYD9(sZK z!LWw{SCyIv!bI%i=X1nZ(TfKsC;V^58k{!V{o}iY3o?araW779{Q12@r&ok}k@vnc zS$y$QvG!K+ZUCoQ5N{`nv?LDk4&YJ}ZUT9aFAsLV5nFQh+o0cT|*r(4Sq$il67n>K4@tq$giRh*r|+(RLSpej?m1Yfv8 z%={ctt9eP(lX-)f~zN^Oe(Kk7ks|}6+@qLwi+aQa$MKCep zSxP-5+yaQveh%caLdRK{O5efXHxUQuxU3BdAQy}ZFz$UF;I%pR^Y`~<%=F25zIsp& z+Ti0U_FNn`Q}%HWI-PDeyT#|hj=#REz;nL3#Qsp=pBogkj#}}PZ1d;;`YxSErG!LV zQ`x;y+k9^ap{|5~#(%$h<`;j=DZAA;MZ?^J)CMS)0*T<&{WVb~Y!(8 zQx2cV6dFdgzSbyRJxbOpSb1P=IZ^ruld5Z|vArgRY+K1LdloWtKQlx^a&eycRR17l zWi+j_B2>gUPNw}xDFKZnq^$c(RpmUPeU%y($o?|o9@mI4sbL~CJkaPqqA}Q;z3|CZ zzz*o>-d2aNr=NsKz7{q)^gL~K0Y@KXCn!ZuP%)_6tf9oFhQmz?&D1OL6`A&Y|JE&Nx5_;PzxJjJdwl4D>S<)FwLIYo8 z4^l@A;1er}p`j;JKhGr2%>IE`Cw_;jSPA>=(l6Om>(nUu2-h!Uqfukj^gBlC5Ca6U z28&D4Lc{(7?uD8q-p~a(8wZ&~+Nf-du&iHmAoDBqyHo(0&IW?I*8%F4apDf1nc?b| zTPY1;lg8xLu+L63H7Gp}{L{DiNxVUuT!5G$AImf+sxjpgF>%Ky>Nkk@cbcZGzz>=_ z_>=OSao!c9lM0@+!#5)_^GmE^K?*Q<5F3<4v=E;)^hZokbxy12#4?ffhx)ZQ##UGX z#i~{;gK^B^j&ix7gmgU!ec)j>E+;o>Y%#x`WPUzUY`s8 z2lpAr?-K=jVpWeUjf_GbLzb>NJw6MWJk)~TE=^^~`|38>@?%^c!ZcQhv8UgYkJBGh z)*lf;=ukQjWcb5y0}?4tVIIvpv$RouaZS$tnB_=`p!9r#t5UI7^dCykvUFOa$u?@- zMn}5I&c-sfCgEhmeSuepV;ay#ZWVAHbJVmguCdlDm(q%9Rj@4{bwxwps!%|CZLe)y zS4iUnPv25kYWCcsYFAf=^Wk>XMp&rr6MS(+Y1Q#%(mb#uL3@ojNAnS9+43dUIq}V? zeQ2nCsVRDi=dXRMDGw|GYv*{CUxg37amFd0h1cU6bLi(p#o&Qjm7b z1-8e!TO^8TKqekIKVK(C8i8uh-+x-hYK?w8WcJuDrN;;?t&v!k@7HF0U$ zod`Dha=)68WzVRHd(@7I-c}Wu!<5$sHIncgIxMTv>T!p+TD*)r^=XO#!({$a_e59X zSw1bc<+(0?Z|~fcx1fr;yv$r>t%DuC-hOgmEkFa&vuDR|;^2YSF)GLc+}NDU=-dTw zH&WWc!>ygCKC6y2p+uBa!6j2q?U-G+YNM!E)*Tn-bX}!eIk6!RMFKk;Og&{}<(hQX}B zZfnU;+^x_SZro^`o*)BJ}0YaOCuuulw%sl3U%4{o1`&@PG6 zh>96Ui>jw#!OLT12+LpJFM_%zq-L^^;fXT3rf7_BR%5G;MDWK^m zn{hFYa-u9MwqaI{mh_9YK1hm#2o_pBcw<~SLZ}yn4_G&Ll{wl8!!S<$YATFr@rhc`7(Ot2X9-Wx69S$~hqlC?M&(c4&;HJC9r+irx*}(-Bx|FA7 z63cKxV`Cin_5vZ-kS;QJF1b-$qMN`0YR}D+R0>rW`ObACJM?&yawVE$f12c$zMBm7 z8LqnpOM2Q{gxk(417e4upP!n=A7`To@pl!K{gEt}`MM<3~S^qpoa8nM2y^l>1pUKE30_LO3) zmp>~|KKly9{Y*}}3!JdRl-0gu8JGM8YpK(-VP+}gHGL_cph7E85lU`+yr1$MI}+|- zQTUr0;2UZ*X3=C{>_TxA@_GSepCmp*E55C7SCl=8O(1Efk+@K zkATon9Sa@}r_E_pRl>T`U9=uuj_=(csJ%V|B-v6X4-bzrt0?s;AkY*oyN+?0s4Ycf=XjtFp&Lt)=e zB7oTw^3^Hu-CUX8ccbX7n&TAL959)veU=20xZ|U?8jU?xqqzbI{2qJ-W`ye);^7np zK-`f!ts8x&8=F9L45s8w=sID=p*vJ#s2tqo#cZx^xaS_~uq;KKW$yvZ5G&G_J&cMJG*Z@V>UQ!dMW4N44G>qWXg9gWp^Av>u>SqQhC7 zLFr4&dT9={X1{~%43eK{uYgMVOnT%4Y|=KD5q;C$yn%lUFK@}ZM5@F_^f}qaC(Cp# z?V7;z9izjuB!7DF5*iYIN;I6DPNr``b>14l^xEl-avP5mD175Gsvm%Vh86O)4Q>za z3}OzapDABRiTz+QLOP<0Ta6J=qI)S9S<<#bI8z+lhn>fu|nY zrTYJgsN*Y_SA_+&4mhynOR7u(Y_aYfCa$+X!ayt=t5Jr@6RfU}v08cMV)c>br(T@< zYME&t=~0FR%S|zmp?~I zvuBERxgCZg>YJs4i_{ngQSmTuz)P$UJ`F zV@NeIt}XF~KJilH48@uoETH6Y^fCsmmTNxK2C+prZIED{IXwgbE45<B!|FEDh-&nES)7<=aR+@KVZ?k*;rZ6Ryrsaw3E0ZHsQu?ffqDx3<- ziGn^Kr19{52R2%5+QG~&Et?yOd}=(JNn%`@S)*I}a-0!1wc3BpLJ&x^`6!fUJk#VeL1u+X66s9op zj7lwCfhpcuJThMFO+2bZ5vgZ94#GaqG9u#sJn`qRusqQzJOacFD&v8mSF&2YYBQV+{jut>{|JJ>0()ARO|sOW>S;KPDDBO# z+Pr0XH|0g0(x2K!9JZ#a?iP}=lO^>>SNIB4XKVgNiZYCA330r6YWr|3Vuoc6fi00d zJ{;z=aO9R`R}pLM?Ke~uJRy~U7>%)ca5XaDIk#We!K2p5!mA?1pHUeJ~~FgVZMuRUvMy~UYmMPSs4`np87Fb z>gMnL+-X?q5;*}{0J(cOC}9dYvyDFZ7vE7baC9tlZzSfi&Nr}FRI9LkO?T+c7@GtU4;gLRLw~9vS_AIKCGJlh#v$@0vYe3M+>Pv9xO>oUha1ZW3M_@U< zlJPsE{W=B55|n#+9x7aIMU<=@dV4U#`T<(u_sr_AYn^&iz*?}s|hk^-AbMokn zF-IiZ^d^2M>J7mp@%#@EcVMd~YD&cNaGE7R?Dga62Rb*D-G4YZsC7BhllTXoyrAnE z!mjNG!YZ4Z-X(=XKRa}&lUQy6#lig_0RjP>qZL=jql>TVoxo@fU~6m7 zmd;j__vWr)gJCP-jk~3cQV~2>PG;O9F>mvX{tjML%MUfBsj{rga z{|OKdF;mRU-%7?V6;B5qJB$qKBl-PHEmu+olVtY_b+y!ElUU|`b$5fT=6w-|{6)%r ze|L=v=u>WRxQT7dhD5Jl?N^U^8SN*R{?i@nHSWGPPyr^;f*F2%)W%H=5b#ZGT)W)HhPdC3OJb&odn_|6CG$rvh^+iJ0oz z)0itN^SDbdSPLV6o6*wPnucWm?(-cWXn@ck;-+|o3l1DBy*V>;|7I7~x{?L&Q&0WL z<40c$z)E9_t2S|6KDmeGXv<7`rf*~n=h-cgf8ibY-9JCnh5lyws)1l~fo0Ij)`5p;Y;nV#NHoe)Djuy8 zwfXv|piLNFNL$*C7-oI|=YGBs3A{YCPd%7QvgOkad#hqx&t|pGfYH)*2DTGn8zXIo zbxNLps-)N^%vOB5wH0x#djbJ|K_9lXc4yov+6{t;v(%gZT4Gc&Y4!~UW<>(=k zSEYbn=)zcG6=%hr6mfd$Q#8ERona{k)|xuMuYrx9C${x&xURs)OCY|ZT-zzc9;p~> z1)*0j+`FPA)w^6_?wUjDww(Icam+r5j9I-gqqg~b@R!^yw2tba$4K@;fU7Mi7 z_!=UFAEkH`OV4hZecT#*29w<)hs)5HC$AWK<85Da|FDJEzzv7(0aOdoO)zT zl#BAY16*eAgiG;$UmmPK%>E|AZmXMIu!xF0ad;sqq}xcr^+s4;1ZT@W`T`FDLKyBO z)pDF-{}$dQ`~2O=9)SI8nn@b+93gOIlD6e?_5_Aq^h{fvI4Mb_Xh7aQQB+j-52Bkxj z1D!*x&0sd|ZCJMHM)M=m7pF#w(>v8s@ll8)N~5MjI6-lqA%Ivwt5-cl${SXkd${GO zEa(x5w8B2Lh!9+AB_l7u&hEkdE4b+tMN&cHadv(*@UiLfVGAgKvwl?g(Wx^e=o2;F zn9j`UPOZi-?ZzEFAl@L~z*LW)nu@&-_H^AV(j!;=u3@^dRz~Mk^(pO)W$FWKHIoPS ztW5Y3jB=Cx9bm>z#LbAy{#Bh+1^}lf+`GVTopSxN(oq|BrlBRwA{Pn%4I1r3N}a{A zjF&+xyo3y|C{<%M-fD_^R(EnVZi#x4?yjp5=EhgGp$*XxC(2^KW`a+@&dzwDNh6*j^uI-yA1BtLyDXWLAv8hi#gEd$06b;i!T&fTdz0`yJ4(<+bD>Zu;PpxCl;#t*loL!-d}OKy#+Bhw3qkay5G#&!u^&SF&{x6YwU zR;_QwMQ7+ox7e=nsz0uN#-(7$H?SivKw>@Q&rd|vhQNZy>uFIptQaj-!5B3Oej($6 zm-yKWZPi(Bq~JSdH`x0W$Ut%yT89e!T%WVJ1eh1sJLuzWAXwnNfKjj)p&LxkmCf6QIVxrSl+%3Duk94Q~gORhu~<0LTxs zPrBgbp0P@;O)m{bE4!@abm%_CJuhU#7tpBQSiJY{-{s&Vts2D#zcs_n&p>wyBQ@b% zS?FF)=P~r=xn;7fnaM6h{S@ht;hSJ?x@&yI{-DSF>d>UHypGS6ur2r6W;kk+yx_Rq zY{N`W5wRBgW>evwqE=5P^tm~Y1oVS0TV#De9->n?VI2PRP`C3^sYlXQwhf~PXdIHD zG1W$BopyvR@!0%K;gGRKoaAegu8>gf4KG(|i8q%0Uigi6jaHID(P^9U7EJ8=PtC*M zgmakn24dHeEOOX%IDgAkl7x$ZG7LPXG8|}P;=)I2a2D?rEhph7o>;gR0=SZ%Z9jLC z<2e+0`J8Hzi`pXL1{wFPfh`Jtw`T~nA|8*@5h+D}$FI?~E(w+~sG?^d67Ws$+a3F> z=+bv;LG0W{6*Fs3979FebW!jsg{GP{1347(Bq>&SN~9 zI|&vA$-t)#^Bww0McOB0MJx(OiQ0YieopK&n?|^9*?dB-iEo{J1ng*SI(^$6P<6dM zUkEUKIe=3eW3hE^0Ur!oVOIAt>8hqNrseQp(@_^diYZ4E{gHq1Jj^V?VQIU83TG-8 zf%1h3HyNw(dgI=<2o(+8j5iM$tE!rEXzYQ5awUJVU)-wMh|ODG`TaYB7rTm{2xwyr za&+X8XS~AwfK!od1Q6f`LH-$JLmqd%;QWyeSs#O|<>x`nQhj<1w_E^5?U({-09^S; z1OG=)E|yq3Tqyt%bKV%m7-Wh2uMbmgG+C^y(wCQZCRTdk$XHV|?>k+{W?o5(Me(hT zCA!{?ElQnn6rqV7t8y_LWK{DFEBLI^X+N1BA^k$>P(STa9)RwP$T_zY<&JveeZVc3 zen3PkW;1Smb!x>t|&Z(3XoaaW^g+p|9 zQ4Z)Sb#8dn7}|A4u|gp1+%v!acJtM1SP9y!Kb)Kc`@i6{v{g#fRZME(`EbV=C%yObc!Sr!U$Ps9XBBmvpHB8zbqWP^eHdqrag3n|M`GryEdVxd# zs+hK3paX^>1wz~wJUEGCJ0b3iu{+#A?4S-|(AwxP9sqQ28&I@IeHmCU&Vtp@6HOh9 zYwyN4jMwZ^Tu#x|%|o(`t51=N=`wu*BY3Bu5o6cY%@D@VzlUYm58jw>lCL$gExA0) zM@G-=uWxg@EBb|Tk}tGpf#5Hd$9jvXx_L~WeW{CZws-brrSo2wN6A9mEW(gKHiJ9l zmr;)uK>(z<3I#&mb5N4rof&5Wg)4@Iq_TWvR;A*y->qebFh1l?#haHAa9%|sz0bq| z+w$+#LH$v&S+c$S-EN|mb|6^R1+!mVz<;KlZ3k~ptit@V1AAYrGK4*-l2ytT3(?6Q z@Fh`T_!NyF1`c9T%ii{CqtK{?I{SUmUO z?NFH?d!rJLpEj?x`x5cEtsmsF;jN-%@g`WgW}mfBx?MCdv~|W~5`|UlAa|!{9g9Z~ zye`hbw(sW8a@>^yVKw$A88bRw>RMeXe+W{$x8)V2P?c zPS-m`e%DF@cCTw5W&&5rtemj5cXR0cC{rEp9@)PYQf~;u_lnyXn=#LQEM8AR8nuu# z&Qrv7Xhx5VCmJHOkJhAhjy&1)C`a*|RRFP08>FK`+$5uV z4}0RICF0xH2&BfUd64KMaZWu=0}7#p4TGS#jLDUAxzBPpVpTBrmt2gkl;{{~o1Hm) zaK*%zer4i_wr|pQ`W0?6zNu4oq67Y!@qZV9$NMKKk!z#;#{M<*gL>VcVo4kM3^DK2 zOjXNG+ln#FcK`C8COS*Kf&JV=&$C=19zZNnJe zUu^c!x^B!A-wl%Jv~FhbwSLv4i8Zw*_<{>bU1s+t^nGNdkE1`7>Zv-Zp8M5O-AW}s z7~rlzYJA*9S5VbjW#0G*cDYFer%sE55d789bPx-X5DGt85Y&ko-q=I>H^S>LT;L3vVoCAXY%Hjd; zeJ(QxR8VSiKDRbgK{&ZQHkQT^POLTAZ}atHqYx3l?1={b0gdCpE?|-aXDRb&^N#B9 zCp8oe#hRvxVJN$&Dfhax)h>&gsLH2M6t*e1#zh8IJf0c`b@fFM53y&S$gXWE%Nsvl zOCFjs;#I*Ogbmy1rd1*k*j)3hd4{vPq%J$Hd8NkAKs#x~4@fG|Na6C;Px z6~g@|;(t1dR6x=x3NjGTBGLc!E0-t+fX#m(meAdTeqrilWkDoGUG&X?#w}KOHj2=p z;9`Aac=292kFe{3oi*5zf5yU37`Dg=!hew_Bu_ziaP-3UDLGf>&p8j>r?N9sFS`bP zz?y?7gY*0Q5BEAPGckPo|Jq0Et8&=N|D~F&J@ZZfOEvMP!uvxi6a`OUB@Ph5@=-bv zzj#ORAZf^lNIkh1Y$^$}s;#Nr(kof3_no*Uixm-G+S_3Mf|+gPBNpCllH@}&677&= zWUOUKWmCZ`zkTn=T3{1^hQAwg2OBIV)b2$87i;o<84no%^;GGgMWQ-4{i}NtvHiwz zb|G)YBLtcD%nXZ9g%D*wqZ@Do1?~sO_YpyF4ADA0=d=5K^$INFVbs+=98ZJR#yn<7 z1rNRsw}5pff#}?P@QN|0S>S!56Lri_lgOhXaxI4j`w!~L39gKL1h(G#`FXE{TW#E+MAr1OTKhR_Q>^M? z*Ezz6y^vt+Mm6qW1(Y_10aS3Viw2NTe{Yw@b5{j#%iKRier)f!5}5ErdfrdqgDQDU z_rUk##BuZLQ@XSiI`1~Y38cjW2N$1AwUdIO!==XxaL6oCxb)^V!$+A@f=L}ujgPrQ zwJH~wjdNBTGRes$gV(8pe6EW?_i|xE=fC(|q6fg^2t{+eisgADbE?w0M+N{@`LLo4 z&RkEB|NV26wIC7yWhra_U+7o;FT|ugO{gTiSV{nP)f%PZ^OE#pehpG36p1GaTF6dY zmQ~}mIjZ}jC(_41<&^SI5aOOl1nPWH(=WnZhrSFmfxaC9AVUxzIDPi4kF$u`5Z!`^ zx8zB1Lgx&NkcC3k4(LW+@dFGtm@wB27|b!W1eqvpbL&^m#1w*t^!M7uSkx%NUvK}4!d)Bo4ina4xb{c(I08Zkzbv9Dt{ks15G z6Ov>}_AQ}Ek}ZsNYza+vGWJr*mXz!vvWrQ!XKYy_Q)r6#P2*RddFJ)Hf8F=%^F8O> zx#ym9?mgensM?)k*PA zVV7XB^gGM`qlmM{Y;|enA9c_9X5^r3zbS~pvonU_whrHS%UN?yd6afx5M{aLp67Z} z*g9t45EtNcuB&d_yGBhO_gWsiKQi*0w;Zoj2mn1kI}BP6W=@>Xsv=aKEq^QTtRCIM z_C9y5L8;j*Gne1HVZm>FG(p(p`m5|YANf$+^JO81b%UHseXx?p*rQ@-3VND6hATa`^K~k%kBv>x^n`1UULQQ@q-CpbZpJv3ev-YCz&~7m5sUQWm_BE{ zMo8T`p)*(lyMti}HJy#zFxW@0!($VD4pWGwg8ot%7@R?6<8v2?FcI4d3+QY9B5rXhpHQ<7K1nIR%sNX~1)Pj0 z0etN=Ot_r^kvnKSF?Zs%#8I$wW@XOHeoy-tLsO=U2pDF?_LGgGBx8mVOgweeZxB34egjnFnimyT3M%7{jKm&<;CM_Fs0jC zH)CEn#~mjKhB2EVm*vzf5mA@WIF;sq)=CFi^OfadO?>pNuROvD8>dkVyY=c;hU-46 zH~LilIs#7|@=acAf%u}nTBmoXo@q%@>?EMHrrHw(+deUrg(S*Fm?CQChf zC6&o8R$rc`;;Jc;#o6AYdCI=U%pr|sz(UE3wztjN;6yY&#H?!GrtRTZmaewx`r6H} z>DM!LUn;vp&~COk|DqhIx9-BV6s48V15UL%0)7(F!_AXUQZ^_kl#6yMVbYAnsdTSs z|5!D47UyIbSH3HN7P=dMU$vAtwQ=`2_Y7pb%wvBze1ZCD;L1vMZNci>;h%OJk7?Fv zAs;hD;<~@;*J{V{&?YmwED3@(j0(|aCRi2ynHsOnPv6Fs>}NV1!@^{;Ox~Y(ihs&6 zJ>Gd{?;N+Q%Jvb|NrrFTti*6ULU?(OLp!VhoufJ;+pFrmn^pK_-3RpTNB3P0wO=m@ z`p#IF`^EXGh3MU$kbE0!^DT;7^pOMdSb31*+40kpteF*11xz34dtU8W$O+5#z>#!s{L7c04ZO`_sk? zYq!$=K1)+x|1{{Z8v)yZ54QXiHFfea=F_sCCuKP$0UNK*20w*znO-$?~p z%8z08G2$pV-x-T}rX4_u7QLm$eUUWkj@rnGl$4SeUf_y=zNzPe96$KNXdT?0U1sUk z!+CoYCK^UAFiy<7`G4BanaUggwYAe_0(jd-8)3rA`eX9gax}|8rm&IGg(|_mGrEn| zX6Bw^uhPp`knKTHkM3kML_s}E6QGtV(yJC`*5an9QCH~;PjkzMtqt*tu8KKX4sWqz zZ(oKyswh`*vUK-kj6Dm7bGV=j({9wYGMvP<{g}MwCYN#VlJ-wFfEJ(C;HqA#>P2%s zzEVz7?}4rE8U953bTl(H?8*el4lcIvYUpDPZwPf;S*_-X_-N1a6voW5c6ZQ7BLzBw zypK^r;Z6Y8YRx%nhHw8^yIlNnZZ**HNNOG`>p08Ijk0ynb8%|)MA>nuQgfx%!;5sp z8Mv(S$?w;^fEQJ8Ni{DF)0S@~0>*`=^70Q`nDsut1@oDwl&_oUA?=F zavrE`Eew*KO}ze1JBhy*Ww(>sNiXHLr$3v>`&Fh16yn7<`*N4qMxY_2WA%xl?w4At ze%%Er`nObl*iG*kD*_e8%7dC$6?z;kBjwUn6cm(xG76;H7{}?Y%Q)rUJ~^4g?1C3Q z`y-oa=lHIO78m4PB+w7fi9mZ1QUY9qT~QEJkwg182S;LC9*mrA$p34-NFCNp=pf z4S_xg{s+N|qP=QgKVtPdgSZyCPJVM+lQzP$P8x-rr#(oo!3+QbNme7jl$kIx8LFsK zg9d&bzJjzDoub2~22rn{sF+)8z`ew%2brNSx&`fCjVCqaq{oIQfX|1hYj(UKYS5&4 zL7@W#uCNXDudIZA?_oi&6+&)q?{wDAR9C>2^df-Z%{A20(wHZa*_ZIz zHV)pVCtTyURsQ_30dDEkT#c?p3EfSryEB?FoNq$s%K~@BZ0-fLL6dt^jms_lD=O{! z0{Wqj412})GQ%M`g}&%G?|F!#cK<^agxlMgBk~aNtGTf#6}wtL?Z^zLX)08Z`MHPH z=|-Q~lv+m8qlJ+cQhvj=%-+b!535Ve61v6=XZx=^W||H8=Jnr@g{tzq4!aw!T3dD_ zYPb5iOkNb3$#5~!xEB?QjZHDYt*@?)CKq{~jw*ZZbscb1Y5+{9skLWv9kRGHbjJ}u zH7P}(RF44gy2U?>p&}w|LL^XbK=UqsCo!6Cuz2ZZrHW%m>*?fosu-MRHAWZC3cUuv zGFL_5FOZXtK8iIlj_9VY^X%C+6T$FJ5eZW~i0V|=+L6jgfTB0(EiLX;#!S#8%y|$4hItGk24PEi>cm@j z)Z==c+~F@XU}bF+_hG>6wejb(b@`E2>s_Ox~Xi8VUDSf_#_lvJS2?Kf}q?!@)F zCm-uu<}1)C8O73@M>ruaO~W4>>eUhNn?b_)`%%(oBRH8sP67#SL{WS%`;7{@AjLTC zBVn0!S|!vr;L@PhwF@RGSv_qPg>I&q7OP!s&QrmrebKU0#}gTuC7(C?l<3FSpdWyr zS$S71ZQ#4J$G%wFpxQ>>f2e+yn(_2!j+?`m_p60_n!&UKKW`ivKUR~pn+e8G# z{Y;o>G5L9etBj4s{Ch6X)AO~!cMGqhS>dY1llN?wjKXp|-@Uh3oq|B@g{o-~waM@e z@ppIAa0E2(Gkl*fx2J{R>%PHE%Ue~KXP_B<$_i#?)!lxAuk}$=r_F4d-CUaQ`KZZwI4}*f+C>#^>t*Gp=ea4c`jBJ|xCgwvCTIpPj|epp>UR$Eq&u6SJ)& z-}rK=)Yy9S$ts4vL3it+U*qQd%IU?}dVDY6nb=E(8Q9pSr5FtdO@f+_Ua<0@Xhd9x z?azIXng@2n!Gka~=ptBR zbYZNMiZh5ad7|>#opA+U9;2O#e*$}UIE&4j%T(Ee#nmD{k@nQ!SA+~DA|*p&ik3PV zuW+>p^brtlfT= z#VU}d%q(=RYDvL9emwA9=eUu~gGR&P8iU(v%k_FujyQu&;GzkA&X*D9#ymsX?*()q znTo`{Xx)OFa%xRz4H>_?LGk&Cu#47K)s3ctxhq@I{V1WvA=jl5|DIs3d+ur z8ql2|Ixb(49eP_jJgm>z>LetWmc|koiR;GV#Fpt&mu3XhYwYz>k+EsCfqP_&6xwH| zIfbOBm<|Zjn|F!MbKsqI8UO%ziIIGu#dUIl?;(sdW%MB(2mmRa)ZZTy7-O2xg^>jF zVUqKixc2Od7L!QN!wFK-QDF)&n~4lEq=s zp(4TGLiYs8I$VGKdr75Npa{4oa=_7Istd%q4y{`MTf<*se>fL0D7g}UeL+c@ND9sP zQ{+m-6pC~GHgWVL0bQgfQ>TD3CCSiwp*+{W%+CJP_>l;VW|1cMU%MgTf3Hrr901cQ zEA_iT++?#H(uICPI!;l;E$WUm}8ULZ^Fs=stWX*r)P~)edZ=e4|12LRsywv23TWV!2N1s?9 zoH)p`#!Z$v+}8vEh*QWc+y0@YxDUSt1OQ+ZRQJvQ2YV>W{rAf?QiDiPP>bPzC?Mu$ zFef?k;rc89z(+yXM*g9XVKyJ~av!d$0RX%dB=`LTG9i)tx8?r3ibN^_<(=N2en4{F zR diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f78a6af..23449a2b5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index adff685a0..23d15a936 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,6 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -171,6 +172,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -210,6 +212,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index e509b2dd8..5eed7ee84 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,10 +70,11 @@ goto fail :execute @rem Setup the command line +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell From 2988de055a910dc960791512686c97b2ca997dd5 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 018/349] feat(compose): Add reply thread support --- app/build.gradle.kts | 1 + .../dankchat/chat/compose/ChatComposable.kt | 26 +- .../chat/compose/ChatComposeViewModel.kt | 34 +- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 116 +- .../chat/compose/EmoteAnimationCoordinator.kt | 20 +- .../chat/compose/EmoteDrawablePainter.kt | 76 ++ .../dankchat/chat/compose/StackedEmote.kt | 77 +- .../compose/TextWithMeasuredInlineContent.kt | 61 +- .../chat/compose/messages/PrivMessage.kt | 40 +- .../compose/messages/WhisperAndRedemption.kt | 36 +- .../chat/mention/compose/MentionComposable.kt | 12 +- .../chat/message/MessageSheetViewModel.kt | 2 +- .../compose/MessageOptionsComposeViewModel.kt | 2 +- .../chat/replies/compose/RepliesComposable.kt | 12 +- .../chat/suggestion/SuggestionProvider.kt | 12 +- .../chat/user/compose/UserPopupDialog.kt | 243 ++-- .../com/flxrs/dankchat/data/irc/IrcMessage.kt | 63 +- .../dankchat/data/repo/RepliesRepository.kt | 68 +- .../dankchat/data/repo/data/DataRepository.kt | 100 +- .../data/repo/emote/EmoteRepository.kt | 162 +-- .../flxrs/dankchat/data/repo/emote/Emotes.kt | 24 + .../data/repo/stream/StreamDataRepository.kt | 69 + .../data/state/ChannelLoadingState.kt | 5 +- .../dankchat/domain/ChannelDataCoordinator.kt | 39 +- .../dankchat/domain/ChannelDataLoader.kt | 52 +- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 37 +- .../com/flxrs/dankchat/main/MainActivity.kt | 137 +- .../com/flxrs/dankchat/main/MainEvent.kt | 10 + .../com/flxrs/dankchat/main/MainFragment.kt | 2 + .../compose/ChannelManagementViewModel.kt | 5 + .../dankchat/main/compose/ChatInputLayout.kt | 429 ++++-- .../main/compose/ChatInputViewModel.kt | 105 +- .../dankchat/main/compose/DraggableHandle.kt | 54 + .../main/compose/EmptyStateContent.kt | 14 +- .../dankchat/main/compose/FloatingToolbar.kt | 414 ++++++ .../main/compose/FullScreenSheetOverlay.kt | 127 ++ .../flxrs/dankchat/main/compose/MainAppBar.kt | 325 ++++- .../dankchat/main/compose/MainEventBus.kt | 10 + .../flxrs/dankchat/main/compose/MainScreen.kt | 1203 ++++++++++------- .../main/compose/MainScreenDialogs.kt | 374 +++++ .../flxrs/dankchat/main/compose/StreamView.kt | 169 +++ .../dankchat/main/compose/StreamViewModel.kt | 88 ++ .../main/compose/dialogs/AddChannelDialog.kt | 7 +- .../main/compose/dialogs/EmoteInfoDialog.kt | 34 +- .../compose/dialogs/ManageChannelsDialog.kt | 30 +- .../compose/dialogs/MessageOptionsDialog.kt | 7 +- .../main/compose/dialogs/RoomStateDialog.kt | 190 +++ .../dankchat/main/compose/sheets/EmoteMenu.kt | 163 +++ .../main/compose/sheets/EmoteMenuSheet.kt | 2 +- .../main/compose/sheets/RepliesSheet.kt | 28 +- .../preferences/DankChatPreferenceStore.kt | 10 + .../utils/extensions/StringExtensions.kt | 48 + app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 15 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 26 + app/src/main/res/values-tr-rTR/strings.xml | 15 + app/src/main/res/values/strings.xml | 13 + gradle/libs.versions.toml | 26 +- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +- gradlew.bat | 3 +- 64 files changed, 4316 insertions(+), 1170 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteDrawablePainter.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/DraggableHandle.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0919e240c..d9b4ced30 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -186,6 +186,7 @@ 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) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 1b61d51eb..20eda7bb3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -1,10 +1,14 @@ package com.flxrs.dankchat.chat.compose +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.LocalPlatformContext +import coil3.imageLoader import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -31,6 +35,12 @@ fun ChatComposable( onEmoteClick: (List) -> Unit, onReplyClick: (String, UserName) -> Unit, modifier: Modifier = Modifier, + showInput: Boolean = true, + isFullscreen: Boolean = false, + hasHelperText: Boolean = false, + onRecover: () -> Unit = {}, + contentPadding: PaddingValues = PaddingValues(), + onScrollDirectionChanged: (Boolean) -> Unit = {}, ) { // Create ChatComposeViewModel with channel-specific key for proper scoping val viewModel: ChatComposeViewModel = koinViewModel( @@ -44,7 +54,12 @@ fun ChatComposable( val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) - + + // Create singleton coordinator using the app's ImageLoader (with disk cache, AnimatedImageDecoder, etc.) + val context = LocalPlatformContext.current + val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) + + CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), @@ -54,6 +69,13 @@ fun ChatComposable( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick + onReplyClick = onReplyClick, + showInput = showInput, + isFullscreen = isFullscreen, + hasHelperText = hasHelperText, + onRecover = onRecover, + contentPadding = contentPadding, + onScrollDirectionChanged = onScrollDirectionChanged ) + } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 2deb9c362..66fe09525 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -8,19 +8,19 @@ import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository 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.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam -import kotlin.time.Duration.Companion.seconds /** * ViewModel for Compose-based chat display. @@ -43,25 +43,41 @@ class ChatComposeViewModel( .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 = HashMap(256) + private var lastAppearanceSettings: AppearanceSettings? = null + private var lastChatSettings: ChatSettings? = null + 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.clear() + lastAppearanceSettings = appearanceSettings + lastChatSettings = chatSettings + } + var messageCount = 0 messages.mapIndexed { index, item -> val isAlternateBackground = when (index) { messages.lastIndex -> messageCount++.isEven else -> (index - messages.size - 1).isEven } + val altBg = isAlternateBackground && appearanceSettings.checkeredMessages + val cacheKey = "${item.message.id}-${item.tag}-$altBg" - item.toChatMessageUiState( - context = context, - appearanceSettings = appearanceSettings, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = isAlternateBackground && appearanceSettings.checkeredMessages - ) + mappingCache.getOrPut(cacheKey) { + item.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg + ) + } } }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 6c3202fa0..a308def18 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -1,17 +1,28 @@ package com.flxrs.dankchat.chat.compose +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.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding 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.FullscreenExit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButton 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -19,12 +30,13 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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.res.stringResource import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.messages.ModerationMessageComposable import com.flxrs.dankchat.chat.compose.messages.NoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.PointRedemptionMessageComposable @@ -56,15 +68,19 @@ fun ChatScreen( animateGifs: Boolean = true, onEmoteClick: (emotes: List) -> Unit = {}, onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit = { _, _ -> }, + showInput: Boolean = true, + isFullscreen: Boolean = false, + hasHelperText: Boolean = false, + onRecover: () -> Unit = {}, + contentPadding: PaddingValues = PaddingValues(), + onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, ) { val listState = rememberLazyListState() - val scope = rememberCoroutineScope() // Track if we should auto-scroll to bottom (sticky state) - // Use rememberSaveable to survive configuration changes (like theme switches) var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } - // Detect if we're showing the newest messages + // Detect if we're showing the newest messages (with reverseLayout, index 0 = newest) val isAtBottom by remember { derivedStateOf { val firstVisibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() @@ -72,11 +88,12 @@ fun ChatScreen( } } - // Disable auto-scroll when user scrolls forward (up in the chat) + // Disable auto-scroll when user scrolls forward (up in chat) LaunchedEffect(listState.isScrollInProgress) { if (listState.lastScrolledForward && shouldAutoScroll) { shouldAutoScroll = false } + onScrollDirectionChanged(listState.lastScrolledForward) } // Auto-scroll when new messages arrive or when re-enabled @@ -86,6 +103,8 @@ fun ChatScreen( } } + val reversedMessages = remember(messages) { messages.asReversed() } + Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -94,10 +113,11 @@ fun ChatScreen( LazyColumn( state = listState, reverseLayout = true, + contentPadding = contentPadding, modifier = Modifier.fillMaxSize() ) { items( - items = messages.asReversed(), + items = reversedMessages, key = { message -> "${message.id}-${message.tag}" }, contentType = { message -> when (message) { @@ -129,26 +149,84 @@ fun ChatScreen( } } - // Scroll to bottom FAB (show when not at bottom) - if (!isAtBottom && messages.isNotEmpty()) { - FloatingActionButton( - onClick = { - shouldAutoScroll = true - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp) + // FABs at bottom-end with coordinated position animation + val showScrollFab = !isAtBottom && messages.isNotEmpty() + val bottomContentPadding = contentPadding.calculateBottomPadding() + val fabBottomPadding by animateDpAsState( + targetValue = when { + showInput -> bottomContentPadding + hasHelperText -> 48.dp + else -> 24.dp + }, + animationSpec = if (showInput) snap() else spring(), + label = "fabBottomPadding" + ) + val recoveryBottomPadding by animateDpAsState( + targetValue = if (showScrollFab) 56.dp + 12.dp else 0.dp, + label = "recoveryBottomPadding" + ) + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + fabBottomPadding), + contentAlignment = Alignment.BottomEnd + ) { + RecoveryFab( + isFullscreen = isFullscreen, + showInput = showInput, + onRecover = onRecover, + modifier = Modifier.padding(bottom = recoveryBottomPadding) + ) + AnimatedVisibility( + visible = showScrollFab, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), ) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to bottom" - ) + FloatingActionButton( + onClick = { + shouldAutoScroll = true + onScrollDirectionChanged(false) + }, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom" + ) + } } } } } } +@Composable +private fun RecoveryFab( + isFullscreen: Boolean, + showInput: Boolean, + onRecover: () -> Unit, + modifier: Modifier = Modifier +) { + val visible = isFullscreen || !showInput + AnimatedVisibility( + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = modifier + ) { + SmallFloatingActionButton( + onClick = onRecover, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = stringResource(R.string.menu_exit_fullscreen) + ) + } + } +} + /** * Renders a single chat message based on its type */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt index f24f4eff7..91c52849e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt @@ -7,6 +7,7 @@ import android.util.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import coil3.DrawableImage import coil3.ImageLoader import coil3.PlatformContext @@ -36,9 +37,12 @@ class EmoteAnimationCoordinator( ) { // LruCache for single emote drawables (like badgeCache in EmoteRepository) private val emoteCache = LruCache(256) - + // LruCache for stacked emote drawables (like layerCache in EmoteRepository) private val layerCache = LruCache(128) + + // Cache of known emote dimensions (width, height in px) to avoid layout shifts + val dimensionCache = LruCache>(512) /** * Get or load an emote drawable. @@ -115,12 +119,22 @@ class EmoteAnimationCoordinator( fun clear() { emoteCache.evictAll() layerCache.evictAll() + dimensionCache.evictAll() } } /** - * Provides a singleton EmoteAnimationCoordinator for the composition. - * This ensures all messages share the same coordinator instance. + * CompositionLocal providing a shared EmoteAnimationCoordinator. + * Must be provided at the chat root (e.g., ChatComposable) so all messages + * share the same coordinator and its LruCache. + */ +val LocalEmoteAnimationCoordinator = staticCompositionLocalOf { + error("No EmoteAnimationCoordinator provided. Wrap your chat composables with CompositionLocalProvider.") +} + +/** + * Creates and remembers a singleton EmoteAnimationCoordinator using the given ImageLoader. + * Call this once at the chat root, then provide via [LocalEmoteAnimationCoordinator]. */ @Composable fun rememberEmoteAnimationCoordinator(imageLoader: ImageLoader): EmoteAnimationCoordinator { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteDrawablePainter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteDrawablePainter.kt new file mode 100644 index 000000000..a245e2bf7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteDrawablePainter.kt @@ -0,0 +1,76 @@ +package com.flxrs.dankchat.chat.compose + +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 + +/** + * A [Painter] that renders a [Drawable] and implements [Drawable.Callback] to support + * animated drawables (GIF/WebP). Unlike [rememberAsyncImagePainter], this maintains + * the callback chain so animations continue after scrolling off/on screen. + */ +@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/compose/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt index 9f4159240..d20f7bbab 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt @@ -1,23 +1,20 @@ package com.flxrs.dankchat.chat.compose -import android.graphics.Rect import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable -import android.widget.ImageView 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.ui.Modifier import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.runtime.remember import coil3.asDrawable import coil3.compose.LocalPlatformContext -import coil3.compose.rememberAsyncImagePainter import coil3.imageLoader import coil3.request.ImageRequest import coil3.size.Size @@ -69,6 +66,11 @@ fun StackedEmote( // 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 @@ -94,31 +96,35 @@ fun StackedEmote( null } }.toTypedArray() - + if (drawables.isNotEmpty()) { // Create LayerDrawable exactly like old implementation 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) } } - - // Render LayerDrawable if available using rememberAsyncImagePainter - layerDrawableState.value?.let { layerDrawable -> + + 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() } - - // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model - val painter = rememberAsyncImagePainter(model = layerDrawable) - + val painter = remember(layerDrawable) { EmoteDrawablePainter(layerDrawable) } + Image( painter = painter, contentDescription = null, @@ -127,6 +133,15 @@ fun StackedEmote( .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() } + ) } } @@ -146,7 +161,10 @@ private fun SingleEmoteDrawable( ) { 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 @@ -164,6 +182,11 @@ private fun SingleEmoteDrawable( // 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 (e: Exception) { @@ -171,22 +194,21 @@ private fun SingleEmoteDrawable( } } } - + // Update animation state when setting changes LaunchedEffect(animateGifs, drawableState.value) { if (drawableState.value is Animatable) { (drawableState.value as Animatable).setRunning(animateGifs) } } - - // Render drawable if available - drawableState.value?.let { drawable -> + + 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() } - - // EXPERIMENT: Try rememberAsyncImagePainter with drawable as model - val painter = rememberAsyncImagePainter(model = drawable) - + val painter = remember(drawable) { EmoteDrawablePainter(drawable) } + Image( painter = painter, contentDescription = null, @@ -195,6 +217,15 @@ private fun SingleEmoteDrawable( .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() } + ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index a2a26ee5c..78cf9e176 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -49,6 +49,7 @@ data class EmoteDimensions( * @param text The AnnotatedString with annotations marking where inline content goes * @param inlineContentProviders Map of content IDs to composables that will be measured * @param modifier Modifier for the text + * @param knownDimensions Optional pre-known dimensions for inline content IDs, skipping measurement subcomposition * @param onTextClick Callback for click events with offset position * @param onTextLongClick Callback for long-click events with offset position * @param interactionSource Optional interaction source for ripple effects @@ -58,6 +59,7 @@ fun TextWithMeasuredInlineContent( text: AnnotatedString, inlineContentProviders: Map Unit>, modifier: Modifier = Modifier, + knownDimensions: Map = emptyMap(), onTextClick: ((Int) -> Unit)? = null, onTextLongClick: ((Int) -> Unit)? = null, interactionSource: MutableInteractionSource? = null, @@ -67,28 +69,35 @@ fun TextWithMeasuredInlineContent( val textLayoutResultRef = remember { mutableStateOf(null) } SubcomposeLayout(modifier = modifier) { constraints -> - // Phase 1: Measure all inline content to get actual dimensions + // Phase 1: Measure inline content to get actual dimensions + // Skip measurement for IDs with pre-known dimensions (from cache) val measuredDimensions = mutableMapOf() - + + // Add all pre-known dimensions first + measuredDimensions.putAll(knownDimensions) + + // Only measure items that don't have known dimensions inlineContentProviders.forEach { (id, provider) -> - val measurables = subcompose("measure_$id", provider) - if (measurables.isNotEmpty()) { - // Measure with unbounded constraints to get natural size - val placeable = measurables.first().measure( - Constraints( - maxWidth = constraints.maxWidth, - maxHeight = Constraints.Infinity + if (id !in knownDimensions) { + val measurables = subcompose("measure_$id", provider) + if (measurables.isNotEmpty()) { + // Measure with unbounded constraints to get natural size + val placeable = measurables.first().measure( + Constraints( + maxWidth = constraints.maxWidth, + maxHeight = Constraints.Infinity + ) ) - ) - measuredDimensions[id] = EmoteDimensions( - id = id, - widthPx = placeable.width, - heightPx = placeable.height - ) + measuredDimensions[id] = EmoteDimensions( + id = id, + widthPx = placeable.width, + heightPx = placeable.height + ) + } } } - - // Phase 2: Create InlineTextContent with measured dimensions + + // Phase 2: Create InlineTextContent with measured/known dimensions val inlineContent = measuredDimensions.mapValues { (id, dimensions) -> InlineTextContent( placeholder = Placeholder( @@ -123,14 +132,24 @@ fun TextWithMeasuredInlineContent( }, onTap = { offset -> textLayoutResultRef.value?.let { layoutResult -> - val position = layoutResult.getOffsetForPosition(offset) - onTextClick?.invoke(position) + val line = layoutResult.getLineForVerticalPosition(offset.y) + val lineLeft = layoutResult.getLineLeft(line) + val lineRight = layoutResult.getLineRight(line) + if (offset.x in lineLeft..lineRight) { + val position = layoutResult.getOffsetForPosition(offset) + onTextClick?.invoke(position) + } } }, onLongPress = { offset -> textLayoutResultRef.value?.let { layoutResult -> - val position = layoutResult.getOffsetForPosition(offset) - onTextLongClick?.invoke(position) + val line = layoutResult.getLineForVerticalPosition(offset.y) + val lineLeft = layoutResult.getLineLeft(line) + val lineRight = layoutResult.getLineRight(line) + if (offset.x in lineLeft..lineRight) { + val position = layoutResult.getOffsetForPosition(offset) + onTextLongClick?.invoke(position) + } } } ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 2a9582d42..8b4bac63f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -38,14 +39,15 @@ import androidx.core.net.toUri import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.chat.compose.EmoteDimensions import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @@ -132,8 +134,7 @@ private fun PrivMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val imageLoader = coil3.ImageLoader.Builder(context).build() - val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) + val emoteCoordinator = LocalEmoteAnimationCoordinator.current val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val nameColor = rememberBackgroundColor(message.lightNameColor, message.darkNameColor) @@ -263,10 +264,41 @@ private fun PrivMessageText( } } + // Compute known dimensions from dimension cache to skip measurement subcomposition + val density = LocalDensity.current + val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { + buildMap { + // Badge dimensions are always known (fixed size) + val badgeSizePx = with(density) { badgeSize.toPx().toInt() } + message.badges.forEach { badge -> + put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) + } + // Emote dimensions from cache + val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + message.emotes.forEach { emote -> + val id = "EMOTE_${emote.code}" + if (emote.urls.size == 1) { + val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } else { + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + val dims = emoteCoordinator.dimensionCache.get(cacheKey) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } + } + } + } + // Use SubcomposeLayout to measure inline content, then render text TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, + knownDimensions = knownDimensions, modifier = Modifier .fillMaxWidth() .alpha(message.textAlpha), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 70ba6039b..42214df92 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -34,13 +35,14 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.EmoteDimensions import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** @@ -89,8 +91,7 @@ private fun WhisperMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val imageLoader = coil3.ImageLoader.Builder(context).build() - val emoteCoordinator = rememberEmoteAnimationCoordinator(imageLoader) + val emoteCoordinator = LocalEmoteAnimationCoordinator.current val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val senderColor = rememberBackgroundColor(message.lightSenderColor, message.darkSenderColor) @@ -213,10 +214,39 @@ private fun WhisperMessageText( } } + // Compute known dimensions from dimension cache to skip measurement subcomposition + val density = LocalDensity.current + val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { + buildMap { + val badgeSizePx = with(density) { badgeSize.toPx().toInt() } + message.badges.forEach { badge -> + put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) + } + val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + message.emotes.forEach { emote -> + val id = "EMOTE_${emote.code}" + if (emote.urls.size == 1) { + val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } else { + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + val dims = emoteCoordinator.dimensionCache.get(cacheKey) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } + } + } + } + // Use SubcomposeLayout to measure inline content, then render text TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, + knownDimensions = knownDimensions, modifier = Modifier.fillMaxWidth(), onTextClick = { offset -> // Handle username clicks diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index 85e000cf7..aafcf92be 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -1,11 +1,16 @@ package com.flxrs.dankchat.chat.mention.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.LocalPlatformContext +import coil3.imageLoader import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -33,7 +38,11 @@ fun MentionComposable( isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) } - + + val context = LocalPlatformContext.current + val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) + + CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), @@ -43,4 +52,5 @@ fun MentionComposable( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick ) + } // CompositionLocalProvider } \ No newline at end of file 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 index 77cc6286f..f406e26c8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetViewModel.kt @@ -44,7 +44,7 @@ class MessageSheetViewModel( 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 replyName = name val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage MessageSheetState.Found( messageId = message.id, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt index 95d4951c9..afc72b6e1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt @@ -51,7 +51,7 @@ class MessageOptionsComposeViewModel( val asWhisperMessage = message as? WhisperMessage val rootId = asPrivMessage?.thread?.rootId val name = asPrivMessage?.name ?: asWhisperMessage?.name ?: return@combine MessageOptionsState.NotFound - val replyName = asPrivMessage?.thread?.name ?: name + val replyName = name val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage MessageOptionsState.Found( messageId = message.id, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index dc0f2eb69..466673f3e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -1,12 +1,17 @@ package com.flxrs.dankchat.chat.replies.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.LocalPlatformContext +import coil3.imageLoader import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.replies.RepliesUiState import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -31,7 +36,11 @@ fun RepliesComposable( ) { val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) - + + val context = LocalPlatformContext.current + val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) + + CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { when (uiState) { is RepliesUiState.Found -> { ChatScreen( @@ -49,4 +58,5 @@ fun RepliesComposable( } } } + } // CompositionLocalProvider } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt index f8a149b84..abcc0ae61 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.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.EmoteRepository import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.flow.Flow @@ -18,6 +19,7 @@ import org.koin.core.annotation.Single class SuggestionProvider( private val emoteRepository: EmoteRepository, private val usersRepository: UsersRepository, + private val commandRepository: CommandRepository, private val chatSettingsDataStore: ChatSettingsDataStore, ) { @@ -75,9 +77,13 @@ class SuggestionProvider( } private fun getCommandSuggestions(channel: UserName, constraint: String): Flow> { - // TODO: Implement actual command fetching from CommandRepository - // For now, return empty list - return flowOf(emptyList()) + return combine( + commandRepository.getCommandTriggers(channel), + commandRepository.getSupibotCommands(channel) + ) { triggers, supibotCommands -> + val allCommands = (triggers + supibotCommands).map { Suggestion.CommandSuggestion(it) } + filterCommands(allCommands, constraint) + } } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index 06cd17e2c..51c884a6c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -10,28 +10,24 @@ 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.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.FlowRow 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.Flag -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Launch -import androidx.compose.material.icons.filled.Message import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Report import androidx.compose.material3.AlertDialog 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.rememberModalBottomSheetState import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.RichTooltip import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipBox @@ -45,9 +41,11 @@ 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.platform.LocalContext 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 androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage @@ -78,143 +76,153 @@ fun UserPopupDialog( ModalBottomSheet( onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { when (val s = state) { is UserPopupState.Loading -> { - CircularProgressIndicator() + CircularProgressIndicator(modifier = Modifier.padding(horizontal = 16.dp)) Text( text = s.userName.formatWithDisplayName(s.displayName), - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp) ) } is UserPopupState.NotLoggedIn -> { Icon( imageVector = Icons.Default.Person, contentDescription = null, - modifier = Modifier.size(64.dp) + modifier = Modifier + .size(96.dp) + .padding(horizontal = 16.dp) ) Text( text = s.userName.formatWithDisplayName(s.displayName), - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp) ) } is UserPopupState.Error -> { - Text("Error: ${s.throwable?.message}") + Text( + text = "Error: ${s.throwable?.message}", + modifier = Modifier.padding(horizontal = 16.dp) + ) } is UserPopupState.Success -> { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.Top ) { - Row(verticalAlignment = Alignment.CenterVertically) { - AsyncImage( - model = s.avatarUrl, - contentDescription = null, - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .clickable { onOpenChannel(s.userName.value) } + AsyncImage( + model = s.avatarUrl, + contentDescription = null, + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .clickable { onOpenChannel(s.userName.value) } + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) { + Text( + text = s.userName.formatWithDisplayName(s.displayName), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = s.userName.formatWithDisplayName(s.displayName), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) + Text( + text = stringResource(R.string.user_popup_created, s.created), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + if (s.showFollowingSince) { Text( - text = stringResource(R.string.user_popup_created, s.created), - style = MaterialTheme.typography.bodyMedium + text = s.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 (s.showFollowingSince) { - Text( - text = s.followingSince?.let { - stringResource(R.string.user_popup_following_since, it) - } ?: stringResource(R.string.user_popup_not_following), - style = MaterialTheme.typography.bodyMedium - ) - } } - } - } - - if (badges.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - items(badges) { badge -> - val title = badge.badge.title - if (title != null) { - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(title) + 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.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(title) + } + }, + state = rememberTooltipState(), + ) { + AsyncImage( + model = badge.url, + contentDescription = title, + modifier = Modifier.size(32.dp) + ) } - }, - 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 { - AsyncImage( - model = badge.url, - contentDescription = null, - modifier = Modifier.size(32.dp) - ) } } } } - Spacer(modifier = Modifier.height(16.dp)) - - Column( - modifier = Modifier.fillMaxWidth() - ) { - UserPopupButton( - icon = Icons.Default.AlternateEmail, - text = stringResource(R.string.user_popup_mention), - onClick = { - onMention(s.userName.value, s.displayName.value) - onDismiss() - } - ) - UserPopupButton( - icon = Icons.AutoMirrored.Filled.Chat, - text = stringResource(R.string.user_popup_whisper), - onClick = { - onWhisper(s.userName.value) - onDismiss() - } - ) - UserPopupButton( - icon = Icons.Default.Block, - text = if (s.isBlocked) stringResource(R.string.user_popup_unblock) else stringResource(R.string.user_popup_block), - onClick = { - if (s.isBlocked) { - onUnblockUser() - } else { - showBlockConfirmation = true - } + ListItem( + headlineContent = { Text(stringResource(R.string.user_popup_mention)) }, + leadingContent = { Icon(Icons.Default.AlternateEmail, contentDescription = null) }, + modifier = Modifier.clickable { + onMention(s.userName.value, s.displayName.value) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + ListItem( + headlineContent = { Text(stringResource(R.string.user_popup_whisper)) }, + leadingContent = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }, + modifier = Modifier.clickable { + onWhisper(s.userName.value) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + ListItem( + headlineContent = { Text(if (s.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 (s.isBlocked) { + onUnblockUser() + } else { + showBlockConfirmation = true } - ) - UserPopupButton( - icon = Icons.Default.Report, - text = stringResource(R.string.user_popup_report), - onClick = { onReport(s.userName.value) } - ) - } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + ListItem( + headlineContent = { Text(stringResource(R.string.user_popup_report)) }, + leadingContent = { Icon(Icons.Default.Report, contentDescription = null) }, + modifier = Modifier.clickable { onReport(s.userName.value) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) } } } @@ -243,24 +251,3 @@ fun UserPopupDialog( ) } } - -@Composable -private fun UserPopupButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, - text: String, - onClick: () -> Unit -) { - TextButton( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = icon, contentDescription = null) - Spacer(modifier = Modifier.width(32.dp)) - Text(text = text, style = MaterialTheme.typography.labelLarge) - } - } -} \ No newline at end of file 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..974019b9f 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 @@ -16,6 +16,34 @@ data class IrcMessage( 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 var nextSpace: Int @@ -36,23 +64,24 @@ 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 } 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..cf387437c 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 @@ -2,6 +2,8 @@ package com.flxrs.dankchat.data.repo import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.toDisplayName +import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.message.HighlightType import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.MessageThread @@ -50,15 +52,17 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS 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 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 ?: return message + val rootMessage = findMessageById(strippedMessage.channel, rootId) as? PrivMessage + ?: createPlaceholderRootMessage(strippedMessage, rootId) + ?: return message MessageThread( rootMessageId = rootId, rootMessage = rootMessage, @@ -81,12 +85,24 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS 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 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 } + } + + 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,9 +110,9 @@ 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 }) @@ -121,11 +137,11 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS } private fun PrivMessage.isParticipating(): Boolean { - return name == dankChatPreferenceStore.userName || (ROOT_MESSAGE_LOGIN_TAG in tags && tags[ROOT_MESSAGE_LOGIN_TAG] == dankChatPreferenceStore.userName?.value) + return name == dankChatPreferenceStore.userName || (PARENT_MESSAGE_LOGIN_TAG in tags && tags[PARENT_MESSAGE_LOGIN_TAG] == dankChatPreferenceStore.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 ") @@ -140,6 +156,25 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS return this } + 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(), + ) + } + private fun PrivMessage.clearHighlight(): PrivMessage { return copy(highlights = highlights.filter { it.type != HighlightType.Reply }.toSet()) } @@ -148,8 +183,13 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS 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" + private const val THREAD_ROOT_USER_ID_TAG = "reply-thread-parent-user-id" } } 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..853002c26 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 @@ -29,9 +29,9 @@ 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,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import java.io.File -import kotlin.system.measureTimeMillis @Single class DataRepository( @@ -100,7 +99,7 @@ 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() @@ -128,23 +127,24 @@ class DataRepository( it.imageLink } - suspend fun loadGlobalBadges() = withContext(Dispatchers.IO) { + suspend fun loadGlobalBadges(): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "global badges") { - val badges = when { + val result = when { dankChatPreferenceStore.isLoggedIn -> helixApiClient.getGlobalBadges().map { it.toBadgeSets() } System.currentTimeMillis() < BADGES_SUNSET_MILLIS -> badgesApiClient.getGlobalBadges().map { it.toBadgeSets() } - else -> return@withContext + else -> return@withContext Result.success(Unit) }.getOrEmitFailure { DataLoadingStep.GlobalBadges } - badges?.also { emoteRepository.setGlobalBadges(it) } + result.onSuccess { emoteRepository.setGlobalBadges(it) }.map { } } } - suspend fun loadDankChatBadges() = withContext(Dispatchers.IO) { - measureTimeMillis { + suspend fun loadDankChatBadges(): Result = withContext(Dispatchers.IO) { + measureTimeAndLog(TAG, "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>) { @@ -155,99 +155,105 @@ class DataRepository( serviceEventChannel.send(ServiceEvent.Shutdown) } - suspend fun loadChannelBadges(channel: UserName, id: UserId) = withContext(Dispatchers.IO) { + suspend fun loadChannelBadges(channel: UserName, id: UserId): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "channel badges for #$id") { - val badges = when { + val result = when { dankChatPreferenceStore.isLoggedIn -> helixApiClient.getChannelBadges(id).map { it.toBadgeSets() } System.currentTimeMillis() < BADGES_SUNSET_MILLIS -> badgesApiClient.getChannelBadges(id).map { it.toBadgeSets() } - else -> return@withContext + else -> return@withContext Result.success(Unit) }.getOrEmitFailure { DataLoadingStep.ChannelBadges(channel, id) } - badges?.also { emoteRepository.setChannelBadges(channel, it) } + 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(Dispatchers.IO) { if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { + measureTimeAndLog(TAG, "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 { it?.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(Dispatchers.IO) { if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { + measureTimeAndLog(TAG, "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 { it?.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(Dispatchers.IO) { if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { + measureTimeAndLog(TAG, "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 loadGlobalFFZEmotes(): Result = withContext(Dispatchers.IO) { if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { + measureTimeAndLog(TAG, "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(Dispatchers.IO) { if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { + measureTimeAndLog(TAG, "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(Dispatchers.IO) { if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { + measureTimeAndLog(TAG, "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 -> + private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = onFailure { throwable -> Log.e(TAG, "Data request failed:", throwable) _dataLoadingFailures.update { it + DataLoadingFailure(step(), throwable) } - null } companion object { 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 a34162a76..e4f4b2776 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,6 +1,5 @@ package com.flxrs.dankchat.data.repo.emote -import android.annotation.SuppressLint import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.os.Build @@ -42,14 +41,14 @@ 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.preferences.chat.ChatSettingsDataStore +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.concurrentMap -import com.flxrs.dankchat.utils.extensions.removeDuplicateWhitespace -import com.flxrs.dankchat.utils.extensions.supplementaryCodePointPositions import kotlinx.coroutines.Dispatchers +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 @@ -71,39 +70,64 @@ class EmoteRepository( private val sevenTvChannelDetails = ConcurrentHashMap() - private val emotes = ConcurrentHashMap>() + private val globalEmoteState = MutableStateFlow(GlobalEmoteState()) + private val channelEmoteStates = ConcurrentHashMap>() val badgeCache = LruCache(64) val layerCache = LruCache(256) - fun getEmotes(channel: UserName): StateFlow = emotes.getOrPut(channel) { MutableStateFlow(Emotes()) } + fun getEmotes(channel: UserName): Flow { + val channelFlow = channelEmoteStates.getOrPut(channel) { MutableStateFlow(ChannelEmoteState()) } + return combine(globalEmoteState, channelFlow, ::mergeEmotes) + } + 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) } fun parse3rdPartyEmotes(message: String, channel: UserName, withTwitch: Boolean = false): List { - val splits = message.split(WHITESPACE_REGEX) - val available = emotes[channel]?.value ?: return emptyList() + val globalState = globalEmoteState.value + val channelState = channelEmoteStates[channel]?.value ?: ChannelEmoteState() + 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 emoteMap = HashMap() + globalState.sevenTvEmotes.associateByTo(emoteMap) { it.code } + globalState.bttvEmotes.associateByTo(emoteMap) { it.code } + globalState.ffzEmotes.associateByTo(emoteMap) { it.code } + if (!isWhisper) { + channelState.sevenTvEmotes.associateByTo(emoteMap) { it.code } + channelState.bttvEmotes.associateByTo(emoteMap) { it.code } + channelState.ffzEmotes.associateByTo(emoteMap) { it.code } + } + if (withTwitch) { + globalState.twitchEmotes.associateByTo(emoteMap) { it.code } + channelState.twitchEmotes.associateByTo(emoteMap) { it.code } + } + // Single pass through words with O(1) lookups + var currentPosition = 0 return buildList { - if (withTwitch) { - addAll(available.twitchEmotes.flatMap { parseMessageForEmote(it, splits) }) - } - - 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) }) + 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 } - - 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 } + } } suspend fun parseEmotesAndBadges(message: Message): Message { @@ -116,10 +140,8 @@ class EmoteRepository( ChatRepository.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( @@ -130,7 +152,8 @@ class EmoteRepository( removedSpaces = removedSpaces, replyMentionOffset = replyMentionOffset ) - val thirdPartyEmotes = parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel).filterNot { e -> twitchEmotes.any { it.code == e.code } } + val twitchEmoteCodes = twitchEmotes.mapTo(HashSet(twitchEmotes.size)) { it.code } + val thirdPartyEmotes = parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel).filterNot { it.code in twitchEmoteCodes } val emotes = (twitchEmotes + thirdPartyEmotes) val (adjustedMessage, adjustedEmotes) = adjustOverlayEmotes(appendedSpaceAdjustedMessage, emotes) @@ -314,10 +337,15 @@ class EmoteRepository( emoteSet.emotes.mapToGenericEmotes(type) } - emotes.forEach { (channel, flow) -> + val globalTwitchEmotes = twitchEmotes.filter { it.emoteType is EmoteType.GlobalTwitchEmote || it.emoteType is EmoteType.ChannelTwitchEmote } + val followerEmotes = twitchEmotes.filter { it.emoteType is EmoteType.ChannelTwitchFollowerEmote } + + globalEmoteState.update { it.copy(twitchEmotes = globalTwitchEmotes) } + + channelEmoteStates.forEach { (channel, flow) -> flow.update { it.copy( - twitchEmotes = twitchEmotes.filterNot { emote -> emote.emoteType is EmoteType.ChannelTwitchFollowerEmote && emote.emoteType.channel != channel } + twitchEmotes = followerEmotes.filterNot { emote -> emote.emoteType is EmoteType.ChannelTwitchFollowerEmote && emote.emoteType.channel != channel } ) } } @@ -330,8 +358,8 @@ class EmoteRepository( 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"] @@ -351,27 +379,19 @@ class EmoteRepository( 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) { 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) { 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) { @@ -389,8 +409,8 @@ class EmoteRepository( 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) } } @@ -406,8 +426,8 @@ class EmoteRepository( 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) } } @@ -418,8 +438,8 @@ class EmoteRepository( 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 -> + channelEmoteStates[channel]?.update { state -> + val updated = state.sevenTvEmotes.mapNotNull { emote -> if (event.removed.any { emote.id == it.id }) { null @@ -435,7 +455,7 @@ class EmoteRepository( } ?: emote } } - emotes.copy(sevenTvChannelEmotes = updated + addedEmotes) + state.copy(sevenTvEmotes = updated + addedEmotes) } } @@ -448,11 +468,7 @@ class EmoteRepository( parseSevenTVEmote(emote, EmoteType.GlobalSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) } - emotes.values.forEach { flow -> - flow.update { - it.copy(sevenTvGlobalEmotes = sevenTvGlobalEmotes) - } - } + globalEmoteState.update { it.copy(sevenTvEmotes = sevenTvGlobalEmotes) } } private val UserName?.twitchEmoteType: EmoteType @@ -537,25 +553,17 @@ 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. + */ + private 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( @@ -567,9 +575,9 @@ 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 removedSpaceExtra = countLessThan(removedSpaces, range.first) + val unicodeExtra = countLessThan(supplementaryCodePointPositions, range.first - removedSpaceExtra) + val spaceExtra = countLessThan(appendedSpaces, range.first + unicodeExtra) val fixedStart = range.first + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset val fixedEnd = range.last + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset 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..42dc3eeae 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 @@ -2,6 +2,30 @@ package com.flxrs.dankchat.data.repo.emote 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(), +) + +fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes = Emotes( + twitchEmotes = global.twitchEmotes + 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(), 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..45aef8c1b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt @@ -0,0 +1,69 @@ +package com.flxrs.dankchat.data.repo.stream + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.main.StreamData +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.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.time.Duration.Companion.seconds + +@Single +class StreamDataRepository( + private val dataRepository: DataRepository, + 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>(emptyList()) + val streamData: StateFlow> = _streamData.asStateFlow() + + fun fetchStreamData(channels: List) { + cancelStreamData() + channels.ifEmpty { return } + + scope.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() + } + + 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 index 3646f9c9c..6ebb49408 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -48,5 +48,8 @@ sealed interface GlobalLoadingState { data object Idle : GlobalLoadingState data object Loading : GlobalLoadingState data object Loaded : GlobalLoadingState - data class Failed(val message: String) : GlobalLoadingState + data class Failed( + val message: String, + val failures: Set = emptySet() + ) : GlobalLoadingState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 7f1fff153..deebdfdb5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.domain import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.data.state.ChannelLoadingState import com.flxrs.dankchat.data.state.GlobalLoadingState @@ -10,7 +11,9 @@ import com.flxrs.dankchat.data.repo.chat.ChatLoadingFailure import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -25,12 +28,15 @@ import java.util.concurrent.ConcurrentHashMap class ChannelDataCoordinator( private val channelDataLoader: ChannelDataLoader, private val globalDataLoader: GlobalDataLoader, + private val chatRepository: ChatRepository, + private val dataRepository: DataRepository, private val userStateRepository: UserStateRepository, private val preferenceStore: DankChatPreferenceStore, dispatchersProvider: DispatchersProvider ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private var globalLoadJob: Job? = null // Track loading state per channel private val channelStates = ConcurrentHashMap>() @@ -61,6 +67,10 @@ class ChannelDataCoordinator( .collect { state -> stateFlow.value = state } + + // After channel data loaded, wait for global data too, then reparse + globalLoadJob?.join() + chatRepository.reparseAllEmotesAndBadges() } } @@ -68,20 +78,25 @@ class ChannelDataCoordinator( * Load global data (once at startup) */ fun loadGlobalData() { - scope.launch { + globalLoadJob = scope.launch { _globalLoadingState.value = GlobalLoadingState.Loading + dataRepository.clearDataLoadingFailures() - globalDataLoader.loadGlobalData() - .onSuccess { - // Load user state emotes if logged in - if (preferenceStore.isLoggedIn) { - loadUserStateEmotesIfAvailable() - } - _globalLoadingState.value = GlobalLoadingState.Loaded - } - .onFailure { error -> - _globalLoadingState.value = GlobalLoadingState.Failed(error.message ?: "Unknown error") - } + val results = globalDataLoader.loadGlobalData() + + // Load user state emotes if logged in + if (preferenceStore.isLoggedIn) { + loadUserStateEmotesIfAvailable() + } + + val failures = dataRepository.dataLoadingFailures.value + _globalLoadingState.value = when { + failures.isEmpty() -> GlobalLoadingState.Loaded + else -> GlobalLoadingState.Failed( + message = "${failures.size} provider(s) failed to load", + failures = failures + ) + } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index 6aff6637d..122f26b65 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -7,7 +7,6 @@ import com.flxrs.dankchat.data.repo.channel.Channel import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.repo.data.DataRepository -import com.flxrs.dankchat.data.repo.data.DataLoadingStep import com.flxrs.dankchat.data.state.ChannelLoadingFailure import com.flxrs.dankchat.data.state.ChannelLoadingState import com.flxrs.dankchat.data.twitch.message.SystemMessageType @@ -24,6 +23,7 @@ class ChannelDataLoader( private val dataRepository: DataRepository, private val chatRepository: ChatRepository, private val channelRepository: ChannelRepository, + private val getChannelsUseCase: GetChannelsUseCase, private val dispatchersProvider: DispatchersProvider ) { @@ -35,8 +35,10 @@ class ChannelDataLoader( emit(ChannelLoadingState.Loading) try { - // Get channel info + // Get channel info - uses GetChannelsUseCase which waits for IRC ROOMSTATE + // if not logged in, matching the legacy MainViewModel.loadData behavior val channelInfo = channelRepository.getChannel(channel) + ?: getChannelsUseCase(listOf(channel)).firstOrNull() if (channelInfo == null) { emit(ChannelLoadingState.Failed("Channel not found", emptyList())) return@flow @@ -47,27 +49,18 @@ class ChannelDataLoader( chatRepository.createFlowsIfNecessary(channel) // Load recent message history first with priority - val messagesResult = runCatching { - chatRepository.loadRecentMessagesIfEnabled(channel) - }.fold( - onSuccess = { null }, - onFailure = { ChannelLoadingFailure.RecentMessages(channel, it) } - ) + // loadRecentMessagesIfEnabled handles errors internally and posts its own system messages + chatRepository.loadRecentMessagesIfEnabled(channel) // Load other data in parallel val failures = withContext(dispatchersProvider.io) { val badgesResult = async { loadChannelBadges(channel, channelInfo.id) } val emotesResults = async { loadChannelEmotes(channel, channelInfo) } - val otherFailures = listOfNotNull( + listOfNotNull( badgesResult.await(), *emotesResults.await().toTypedArray(), ) - if (messagesResult != null) { - otherFailures + messagesResult - } else { - otherFailures - } } // Report failures as system messages like legacy implementation @@ -84,9 +77,6 @@ class ChannelDataLoader( } } - // Reparse emotes/badges - this updates the tag which triggers LazyColumn recomposition - chatRepository.reparseAllEmotesAndBadges() - when { failures.isEmpty() -> emit(ChannelLoadingState.Loaded) else -> emit(ChannelLoadingState.Failed("Some data failed to load", failures)) @@ -100,9 +90,7 @@ class ChannelDataLoader( channel: UserName, channelId: UserId ): ChannelLoadingFailure.Badges? { - return runCatching { - dataRepository.loadChannelBadges(channel, channelId) - }.fold( + return dataRepository.loadChannelBadges(channel, channelId).fold( onSuccess = { null }, onFailure = { ChannelLoadingFailure.Badges(channel, channelId, it) } ) @@ -114,25 +102,19 @@ class ChannelDataLoader( ): List { return withContext(dispatchersProvider.io) { val bttvResult = async { - runCatching { - dataRepository.loadChannelBTTVEmotes(channel, channelInfo.displayName, channelInfo.id) - }.fold( + dataRepository.loadChannelBTTVEmotes(channel, channelInfo.displayName, channelInfo.id).fold( onSuccess = { null }, onFailure = { ChannelLoadingFailure.BTTVEmotes(channel, it) } ) } val ffzResult = async { - runCatching { - dataRepository.loadChannelFFZEmotes(channel, channelInfo.id) - }.fold( + dataRepository.loadChannelFFZEmotes(channel, channelInfo.id).fold( onSuccess = { null }, onFailure = { ChannelLoadingFailure.FFZEmotes(channel, it) } ) } val sevenTvResult = async { - runCatching { - dataRepository.loadChannelSevenTVEmotes(channel, channelInfo.id) - }.fold( + dataRepository.loadChannelSevenTVEmotes(channel, channelInfo.id).fold( onSuccess = { null }, onFailure = { ChannelLoadingFailure.SevenTVEmotes(channel, it) } ) @@ -146,14 +128,8 @@ class ChannelDataLoader( } } - suspend fun loadRecentMessages( - channel: UserName - ): ChannelLoadingFailure.RecentMessages? { - return runCatching { - chatRepository.loadRecentMessagesIfEnabled(channel) - }.fold( - onSuccess = { null }, - onFailure = { ChannelLoadingFailure.RecentMessages(channel, it) } - ) + suspend fun loadRecentMessages(channel: UserName) { + // loadRecentMessagesIfEnabled handles errors internally and posts its own system messages + chatRepository.loadRecentMessagesIfEnabled(channel) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 1acb16bf2..28182ed89 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -7,6 +7,7 @@ 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 @@ -20,27 +21,27 @@ class GlobalDataLoader( /** * Load all global data (badges, emotes, commands, blocks) + * Returns the list of Results from each emote/badge provider. */ - suspend fun loadGlobalData(): Result = withContext(dispatchersProvider.io) { - runCatching { - awaitAll( - async { loadDankChatBadges() }, - async { loadGlobalBadges() }, - async { loadGlobalBTTVEmotes() }, - async { loadGlobalFFZEmotes() }, - async { loadGlobalSevenTVEmotes() }, - async { loadSupibotCommands() }, - async { loadUserBlocks() } - ) - Unit - } + suspend fun loadGlobalData(): List> = withContext(dispatchersProvider.io) { + val results = awaitAll( + async { loadDankChatBadges() }, + async { loadGlobalBadges() }, + async { loadGlobalBTTVEmotes() }, + async { loadGlobalFFZEmotes() }, + async { loadGlobalSevenTVEmotes() }, + ) + // Fire-and-forget tasks that handle their own errors + launch { loadSupibotCommands() } + launch { loadUserBlocks() } + results } - suspend fun loadDankChatBadges() = dataRepository.loadDankChatBadges() - suspend fun loadGlobalBadges() = dataRepository.loadGlobalBadges() - suspend fun loadGlobalBTTVEmotes() = dataRepository.loadGlobalBTTVEmotes() - suspend fun loadGlobalFFZEmotes() = dataRepository.loadGlobalFFZEmotes() - suspend fun loadGlobalSevenTVEmotes() = dataRepository.loadGlobalSevenTVEmotes() + 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() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index d4a1394f9..c99711096 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -2,17 +2,25 @@ package com.flxrs.dankchat.main import android.Manifest import android.annotation.SuppressLint +import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.net.Uri import android.os.Bundle import android.os.IBinder +import android.provider.MediaStore import android.util.Log +import android.webkit.MimeTypeMap 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.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -40,8 +48,10 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.findNavController import androidx.navigation.toRoute +import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R +import com.flxrs.dankchat.ValidationResult import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.notification.NotificationService import com.flxrs.dankchat.data.repo.data.ServiceEvent @@ -67,6 +77,10 @@ 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.theme.DankChatTheme +import com.flxrs.dankchat.data.api.ApiException +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.utils.createMediaFile +import com.flxrs.dankchat.utils.removeExifAttributes import com.flxrs.dankchat.utils.extensions.hasPermission import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu import com.flxrs.dankchat.utils.extensions.isInSupportedPictureInPictureMode @@ -74,12 +88,15 @@ 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.Dispatchers 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 org.koin.compose.viewmodel.koinViewModel +import java.io.IOException class MainActivity : AppCompatActivity() { @@ -87,16 +104,49 @@ class MainActivity : AppCompatActivity() { private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() private val dankChatPreferenceStore: DankChatPreferenceStore by inject() private val mainEventBus: MainEventBus by inject() + private val dataRepository: DataRepository by inject() private val pendingChannelsToClear = mutableListOf() private var navController: NavController? = null private var bindingRef: MainActivityBinding? = null private val binding get() = bindingRef + private var currentMediaUri: Uri = Uri.EMPTY 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 requestImageCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) handleCaptureRequest(imageCapture = true) + } + + private val requestVideoCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.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() var notificationService: NotificationService? = null var isBound = false @@ -164,6 +214,16 @@ class MainActivity : AppCompatActivity() { } private fun setupComposeUi() { + lifecycleScope.launch { + viewModel.validationResult.collect { result -> + when (result) { + is ValidationResult.User -> mainEventBus.emitEvent(MainEvent.LoginValidated(result.username)) + is ValidationResult.IncompleteScopes -> mainEventBus.emitEvent(MainEvent.LoginOutdated(result.username)) + ValidationResult.TokenInvalid -> mainEventBus.emitEvent(MainEvent.LoginTokenInvalid) + ValidationResult.Failure -> mainEventBus.emitEvent(MainEvent.LoginValidationFailed) + } + } + } setContent { DankChatTheme { val navController = rememberNavController() @@ -228,13 +288,13 @@ class MainActivity : AppCompatActivity() { // Handled in MainScreen with ViewModel }, onCaptureImage = { - // TODO: Implement camera capture + startCameraCapture(captureVideo = false) }, onCaptureVideo = { - // TODO: Implement camera capture + startCameraCapture(captureVideo = true) }, onChooseMedia = { - // TODO: Implement media picker + requestGalleryMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) } ) } @@ -515,6 +575,13 @@ class MainActivity : AppCompatActivity() { return navController?.navigateUp() ?: false || super.onSupportNavigateUp() } + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: android.content.res.Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (developerSettingsDataStore.current().useComposeChatUi) { + mainEventBus.setInPipMode(isInPictureInPictureMode) + } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val channelExtra = intent.parcelable(OPEN_CHANNEL_KEY) @@ -561,6 +628,70 @@ class MainActivity : AppCompatActivity() { 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 { + val uri = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", it) + 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(Dispatchers.IO) { + if (imageCapture) { + runCatching { file.removeExifAttributes() } + } + } + val result = withContext(Dispatchers.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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt index 3477786f1..e6e13a7e6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt @@ -1,6 +1,16 @@ package com.flxrs.dankchat.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 LoginValidated(val username: UserName) : MainEvent + data class LoginOutdated(val username: UserName) : MainEvent + data object LoginTokenInvalid : MainEvent + data object LoginValidationFailed : 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 index a3308d2c0..e3354d847 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt @@ -461,6 +461,8 @@ class MainFragment : Fragment() { when (it) { is MainEvent.Error -> handleErrorEvent(it) MainEvent.LogOutRequested -> showLogoutConfirmationDialog() + is MainEvent.UploadSuccess, is MainEvent.UploadFailed, MainEvent.UploadLoading -> Unit + is MainEvent.LoginValidated, is MainEvent.LoginOutdated, MainEvent.LoginTokenInvalid, MainEvent.LoginValidationFailed -> Unit } } collectFlow(channelMentionCount, ::updateChannelMentionBadges) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index 9e6099f3a..bcd1ed21d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -66,9 +66,14 @@ class ChannelManagementViewModel( } fun removeChannel(channel: UserName) { + val wasActive = chatRepository.activeChannel.value == channel preferenceStore.removeChannel(channel) chatRepository.updateChannels(preferenceStore.channels) channelDataCoordinator.cleanupChannel(channel) + + if (wasActive) { + chatRepository.setActiveChannel(preferenceStore.channels.firstOrNull()) + } } fun renameChannel(channel: UserName, displayName: String?) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 5c48107f6..64cac6d8c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,64 +1,78 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateDpAsState +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.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation -import androidx.compose.foundation.indication -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.basicMarquee 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.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions +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.Repeat +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.MoreVert +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.VisibilityOff +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator 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.material3.ripple 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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput 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.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.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.ImeAction @Composable fun ChatInputLayout( @@ -68,12 +82,24 @@ fun ChatInputLayout( canSend: Boolean, showReplyOverlay: Boolean, replyName: UserName?, + isEmoteMenuOpen: Boolean, + helperText: String?, + isUploading: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + isStreamActive: Boolean, + hasStreamData: Boolean, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, onReplyDismiss: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + onToggleStream: () -> Unit, + onChangeRoomState: () -> Unit, modifier: Modifier = Modifier ) { + val focusRequester = remember { FocusRequester() } val hint = when (inputState) { InputState.Default -> stringResource(R.string.hint_connected) InputState.Replying -> stringResource(R.string.hint_replying) @@ -81,119 +107,298 @@ fun ChatInputLayout( InputState.Disconnected -> stringResource(R.string.hint_disconnected) } - Surface( - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() + 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 + } + + var quickActionsExpanded by remember { mutableStateOf(false) } + val topEndRadius by animateDpAsState( + targetValue = if (quickActionsExpanded) 0.dp else 24.dp, + label = "topEndCornerRadius" + ) + + Box(modifier = modifier.fillMaxWidth()) { + Surface( + shape = RoundedCornerShape(topStart = 24.dp, topEnd = topEndRadius), + color = surfaceColor, + modifier = Modifier.fillMaxWidth() ) { - // Reply Header - AnimatedVisibility( - visible = showReplyOverlay && replyName != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() ) { - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + // Reply Header + AnimatedVisibility( + visible = showReplyOverlay && replyName != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + 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 = stringResource(R.string.reply_header, replyName?.value.orEmpty()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + IconButton( + onClick = onReplyDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + modifier = Modifier.size(16.dp) + ) + } + } + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + + // Text Field + TextField( + state = textFieldState, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .padding(bottom = 0.dp), // Reduce bottom padding as actions are below + label = { Text(hint) }, + colors = textFieldColors, + shape = RoundedCornerShape(0.dp), + lineLimits = TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = 5 + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + onKeyboardAction = { if (canSend) onSend() } + ) + + // Helper text (roomstate + live info) + AnimatedVisibility( + visible = !helperText.isNullOrEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Text( + text = helperText.orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 4.dp) + .basicMarquee(), + textAlign = TextAlign.Start + ) + } + + // Upload progress indicator + AnimatedVisibility( + visible = isUploading, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + LinearProgressIndicator( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + // Actions Row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + ) { + // Emote/Keyboard Button (Left) + IconButton( + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() + } + onEmoteClick() + }, + enabled = enabled, + modifier = Modifier.size(40.dp) ) { - Text( - text = stringResource(R.string.reply_header, replyName?.value.orEmpty()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - IconButton( - onClick = onReplyDismiss, - modifier = Modifier.size(24.dp) - ) { + if (isEmoteMenuOpen) { Icon( - imageVector = Icons.Default.Close, + imageVector = Icons.Default.Keyboard, contentDescription = stringResource(R.string.dialog_dismiss), - modifier = Modifier.size(16.dp) + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = stringResource(R.string.emote_menu_hint), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } - HorizontalDivider( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - modifier = Modifier.padding(horizontal = 16.dp) + + Spacer(modifier = Modifier.weight(1f)) + + // Quick Actions Button + IconButton( + onClick = { quickActionsExpanded = !quickActionsExpanded }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // History Button (Always visible) + IconButton( + onClick = onLastMessageClick, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.resume_scroll), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Send Button (Right) + SendButton( + enabled = canSend, + onSend = onSend, + modifier = Modifier ) } } + } - // Text Field - TextField( - state = textFieldState, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 0.dp), // Reduce bottom padding as actions are below - label = { Text(hint) }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent - ), - shape = RoundedCornerShape(0.dp), - lineLimits = TextFieldLineLimits.MultiLine( - minHeightInLines = 1, - maxHeightInLines = 5 - ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - onKeyboardAction = { if (canSend) onSend() } - ) - - // Actions Row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) - ) { - // Emote Button (Left) - IconButton( - onClick = onEmoteClick, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.emote_menu_hint), - tint = MaterialTheme.colorScheme.onSurfaceVariant + // Quick actions menu — Popup with custom positioning and slide animation + val menuVisibleState = remember { MutableTransitionState(false) } + menuVisibleState.targetState = quickActionsExpanded + + if (menuVisibleState.currentState || menuVisibleState.targetState) { + val positionProvider = remember { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset = IntOffset( + x = anchorBounds.right - popupContentSize.width, + y = anchorBounds.top - popupContentSize.height ) } + } - Spacer(modifier = Modifier.weight(1f)) - - // History Button (Always visible) - IconButton( - onClick = onLastMessageClick, - enabled = enabled, - modifier = Modifier.size(40.dp) + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = { quickActionsExpanded = false }, + properties = PopupProperties(focusable = true), + ) { + AnimatedVisibility( + visibleState = menuVisibleState, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 150) + ) + fadeIn(animationSpec = tween(durationMillis = 100)), + exit = shrinkVertically( + shrinkTowards = Alignment.Bottom, + animationSpec = tween(durationMillis = 120) + ) + fadeOut(animationSpec = tween(durationMillis = 80)), ) { - Icon( - imageVector = Icons.Default.History, - contentDescription = stringResource(R.string.resume_scroll), // Using resume_scroll as a placeholder for "History/Last message" context - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + Surface( + shape = RoundedCornerShape(topStart = 12.dp), + color = surfaceColor, + ) { + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + if (hasStreamData || isStreamActive) { + DropdownMenuItem( + text = { Text(stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) }, + onClick = { + onToggleStream() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + contentDescription = null + ) + } + ) + } + DropdownMenuItem( + text = { Text(stringResource(if (isFullscreen) R.string.menu_exit_fullscreen else R.string.menu_fullscreen)) }, + onClick = { + onToggleFullscreen() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_hide_input)) }, + onClick = { + onToggleInput() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = null + ) + } + ) + if (isModerator) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_room_state)) }, + onClick = { + onChangeRoomState() + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null + ) + } + ) + } + } + } } - - // Send Button (Right) - SendButton( - enabled = canSend, - onSend = onSend, - modifier = Modifier - ) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index e38595d57..ee96a74d3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -13,6 +13,7 @@ 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.stream.StreamDataRepository import com.flxrs.dankchat.data.repo.command.CommandResult import com.flxrs.dankchat.data.twitch.chat.ConnectionState import com.flxrs.dankchat.data.twitch.command.TwitchCommand @@ -21,18 +22,23 @@ import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.main.RepeatedSendData import com.flxrs.dankchat.main.compose.FullScreenSheetState 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.stream.StreamsSettingsDataStore import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job 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 @@ -48,7 +54,10 @@ class ChatInputViewModel( private val userStateRepository: UserStateRepository, private val suggestionProvider: SuggestionProvider, private val preferenceStore: DankChatPreferenceStore, + private val chatSettingsDataStore: ChatSettingsDataStore, private val developerSettingsDataStore: DeveloperSettingsDataStore, + private val streamDataRepository: StreamDataRepository, + private val streamsSettingsDataStore: StreamsSettingsDataStore, private val mainEventBus: MainEventBus, ) : ViewModel() { @@ -62,6 +71,8 @@ class ChatInputViewModel( private val repeatedSend = MutableStateFlow(RepeatedSendData(enabled = false, message = "")) private val fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) private val mentionSheetTab = MutableStateFlow(0) + private val _isEmoteMenuOpen = MutableStateFlow(false) + val isEmoteMenuOpen = _isEmoteMenuOpen.asStateFlow() // Create flow from TextFieldState private val textFlow = snapshotFlow { textFieldState.text.toString() } @@ -79,6 +90,44 @@ class ChatInputViewModel( suggestionProvider.getSuggestions(text, channel) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private val roomStateDisplayText: StateFlow = combine( + chatSettingsDataStore.showChatModes, + chatRepository.activeChannel + ) { showModes, channel -> + showModes to channel + }.flatMapLatest { (showModes, channel) -> + if (!showModes || channel == null) flowOf(null) + else channelRepository.getRoomStateFlow(channel).map { it.toDisplayText().ifEmpty { null } } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val currentStreamInfo: StateFlow = combine( + streamsSettingsDataStore.showStreamsInfo, + chatRepository.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( + roomStateDisplayText, + currentStreamInfo + ) { roomState, streamInfo -> + listOfNotNull(roomState, streamInfo) + .joinToString(separator = " - ") + .ifEmpty { null } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val hasStreamData: StateFlow = combine( + chatRepository.activeChannel, + streamDataRepository.streamData + ) { activeChannel, streamData -> + activeChannel != null && streamData.any { it.channel == activeChannel } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + private var _uiState: StateFlow? = null init { @@ -100,6 +149,20 @@ class ChatInputViewModel( } } } + + // Trigger stream data fetching whenever channels change + viewModelScope.launch { + chatRepository.channels.collect { channels -> + if (channels != null) { + streamDataRepository.fetchStreamData(channels) + } + } + } + } + + override fun onCleared() { + super.onCleared() + streamDataRepository.cancelStreamData() } private data class UiDependencies( @@ -115,7 +178,8 @@ class ChatInputViewModel( val tab: Int, val isReplying: Boolean, val replyName: UserName?, - val replyMessageId: String? + val replyMessageId: String?, + val isEmoteMenuOpen: Boolean ) fun uiState(fullScreenSheetState: StateFlow, mentionSheetTab: StateFlow): StateFlow { @@ -134,20 +198,29 @@ class ChatInputViewModel( UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn) } - val sheetAndReplyFlow = combine( - fullScreenSheetState, - mentionSheetTab, + val replyStateFlow = combine( _isReplying, _replyName, _replyMessageId - ) { sheetState, tab, isReplying, replyName, replyMessageId -> - SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId) + ) { isReplying, replyName, replyMessageId -> + Triple(isReplying, replyName, replyMessageId) + } + + val sheetAndReplyFlow = combine( + fullScreenSheetState, + mentionSheetTab, + replyStateFlow, + _isEmoteMenuOpen + ) { sheetState, tab, replyState, isEmoteMenuOpen -> + val (isReplying, replyName, replyMessageId) = replyState + SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen) } _uiState = combine( baseFlow, - sheetAndReplyFlow - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId) -> + sheetAndReplyFlow, + helperText + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen), helperText -> this.fullScreenSheetState.value = sheetState this.mentionSheetTab.value = tab @@ -181,7 +254,9 @@ class ChatInputViewModel( inputState = inputState, showReplyOverlay = showReplyOverlay, replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, - replyName = effectiveReplyName + replyName = effectiveReplyName, + isEmoteMenuOpen = isEmoteMenuOpen, + helperText = helperText ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) @@ -314,6 +389,14 @@ class ChatInputViewModel( } } + fun toggleEmoteMenu() { + _isEmoteMenuOpen.update { !it } + } + + fun setEmoteMenuOpen(open: Boolean) { + _isEmoteMenuOpen.value = open + } + companion object { private const val SUGGESTION_DEBOUNCE_MS = 20L } @@ -331,4 +414,6 @@ data class ChatInputUiState( val showReplyOverlay: Boolean = false, val replyMessageId: String? = null, val replyName: UserName? = null, -) \ No newline at end of file + val isEmoteMenuOpen: Boolean = false, + val helperText: String? = null +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DraggableHandle.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DraggableHandle.kt new file mode 100644 index 000000000..600bf79b1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DraggableHandle.kt @@ -0,0 +1,54 @@ +package com.flxrs.dankchat.main.compose + +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, + 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/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt index 043983b59..5b838c8f9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt @@ -30,8 +30,6 @@ fun EmptyStateContent( onAddChannel: () -> Unit, onLogin: () -> Unit, onToggleAppBar: () -> Unit, - onToggleFullscreen: () -> Unit, - onToggleInput: () -> Unit, modifier: Modifier = Modifier ) { Surface(modifier = modifier) { @@ -70,17 +68,7 @@ fun EmptyStateContent( AssistChip( onClick = onToggleAppBar, - label = { Text("Toggle App Bar") } // Consider using resources - ) - - AssistChip( - onClick = onToggleFullscreen, - label = { Text("Toggle Fullscreen") } - ) - - AssistChip( - onClick = onToggleInput, - label = { Text("Toggle Input") } + label = { Text("Toggle App Bar") } ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt new file mode 100644 index 000000000..a2a065a8b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -0,0 +1,414 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +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.interaction.MutableInteractionSource +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.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.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Badge +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +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.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.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.R +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.layout.layout +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FloatingToolbar( + tabState: ChannelTabUiState, + composePagerState: PagerState, + showAppBar: Boolean, + isFullscreen: Boolean, + isLoggedIn: Boolean, + currentStream: UserName?, + hasStreamData: Boolean, + streamHeightDp: Dp, + totalMentionCount: Int, + onTabSelected: (Int) -> Unit, + onTabLongClick: (Int) -> Unit, + onAddChannel: () -> Unit, + onOpenMentions: () -> Unit, + // Overflow menu callbacks + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onToggleStream: () -> Unit, + onOpenSettings: () -> Unit, + endAligned: Boolean = false, + showTabs: Boolean = true, + modifier: Modifier = Modifier, +) { + if (tabState.tabs.isEmpty()) return + + val density = LocalDensity.current + val scope = rememberCoroutineScope() + var isTabsExpanded by remember { mutableStateOf(false) } + var showOverflowMenu by remember { mutableStateOf(false) } + var overflowInitialMenu by remember { mutableStateOf(AppBarMenu.Main) } + + val totalTabs = tabState.tabs.size + val hasOverflow = totalTabs > 3 + val selectedIndex = tabState.selectedIndex + val tabListState = rememberLazyListState() + + // Expand tabs when pager is swiped in a direction with more channels + LaunchedEffect(composePagerState.isScrollInProgress) { + if (composePagerState.isScrollInProgress && hasOverflow) { + val canScroll = tabListState.canScrollForward || tabListState.canScrollBackward + if (!canScroll) return@LaunchedEffect // all tabs fit, don't expand + val offset = snapshotFlow { composePagerState.currentPageOffsetFraction } + .first { it != 0f } + val current = composePagerState.currentPage + val swipingForward = offset > 0 + val swipingBackward = offset < 0 + if ((swipingForward && current < totalTabs - 1) || (swipingBackward && current > 0)) { + isTabsExpanded = true + } + } + } + + // Auto-collapse after scroll stops + 2s delay + LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress) { + if (isTabsExpanded && !composePagerState.isScrollInProgress) { + delay(2000) + isTabsExpanded = false + } + } + + // Reset expanded state when toolbar hides (e.g. keyboard opens in split mode) + LaunchedEffect(showAppBar) { + if (!showAppBar) { + isTabsExpanded = false + showOverflowMenu = false + } + } + + // Dismiss scrim for inline overflow menu + if (showOverflowMenu) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + } + ) + } + + AnimatedVisibility( + visible = showAppBar && !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = modifier + .fillMaxWidth() + .padding(top = if (currentStream != null && streamHeightDp > 0.dp) streamHeightDp else with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .padding(top = 8.dp) + ) { + // Auto-scroll to keep selected tab visible + LaunchedEffect(selectedIndex) { + tabListState.animateScrollToItem(selectedIndex) + } + + // Mention indicators based on visibility + val visibleItems = tabListState.layoutInfo.visibleItemsInfo + val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 + val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) + val hasLeftMention = tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } + val hasRightMention = tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.Top + ) { + // Scrollable tabs pill + AnimatedVisibility( + visible = showTabs, + modifier = Modifier.weight(1f, fill = endAligned), + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), + ) { + Box(modifier = if (endAligned) Modifier.fillMaxWidth() else Modifier) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + val mentionGradientColor = MaterialTheme.colorScheme.error + LazyRow( + state = tabListState, + contentPadding = PaddingValues(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 8.dp) + .wrapLazyRowContent(tabListState) + .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) + ) + } + } + ) { + itemsIndexed( + items = tabState.tabs, + key = { _, tab -> tab.channel.value } + ) { index, tab -> + val isSelected = tab.isSelected + val textColor = when { + isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .combinedClickable( + onClick = { onTabSelected(index) }, + onLongClick = { + onTabLongClick(index) + overflowInitialMenu = AppBarMenu.Channel + showOverflowMenu = true + } + ) + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 12.dp) + ) { + Text( + text = tab.displayName, + color = textColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(4.dp)) + Badge() + } + } + } + } + } + } + } + + // Action icons + inline overflow menu (animated with expand/collapse) + AnimatedVisibility( + visible = !isTabsExpanded, + enter = expandHorizontally( + expandFrom = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeIn(tween(200)), + exit = shrinkHorizontally( + shrinkTowards = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeOut(tween(150)) + ) { + Row(verticalAlignment = Alignment.Top) { + Spacer(Modifier.width(8.dp)) + + val pillCornerRadius by animateDpAsState( + targetValue = if (showOverflowMenu) 0.dp else 28.dp, + animationSpec = tween(200), + label = "pillCorner" + ) + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Surface( + shape = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = pillCornerRadius, + bottomEnd = pillCornerRadius + ), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onAddChannel) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_channel) + ) + } + IconButton(onClick = onOpenMentions) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.mentions_title), + tint = if (totalMentionCount > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + ) + } + IconButton(onClick = { + overflowInitialMenu = AppBarMenu.Main + showOverflowMenu = !showOverflowMenu + }) { + 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() + ) { + Surface( + shape = RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomStart = 12.dp, + bottomEnd = 12.dp + ), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + InlineOverflowMenu( + isLoggedIn = isLoggedIn, + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, + onDismiss = { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + }, + initialMenu = overflowInitialMenu, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = onLogout, + onManageChannels = onManageChannels, + onOpenChannel = onOpenChannel, + onRemoveChannel = onRemoveChannel, + onReportChannel = onReportChannel, + onBlockChannel = onBlockChannel, + onCaptureImage = onCaptureImage, + onCaptureVideo = onCaptureVideo, + onChooseMedia = onChooseMedia, + onReloadEmotes = onReloadEmotes, + onReconnect = onReconnect, + onClearChat = onClearChat, + onToggleStream = onToggleStream, + onOpenSettings = onOpenSettings + ) + } + } + } + } + } + } + } +} + +/** Measures [LazyRow] at full width (for scrolling) but reports actual content width so the pill wraps content. */ +private fun Modifier.wrapLazyRowContent(listState: LazyListState) = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val items = listState.layoutInfo.visibleItemsInfo + val contentWidth = if (items.isNotEmpty()) { + val lastItem = items.last() + lastItem.offset + lastItem.size + listState.layoutInfo.afterContentPadding + } else { + placeable.width + } + val width = contentWidth.coerceAtMost(placeable.width) + layout(width, placeable.height) { + placeable.place(0, 0) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt new file mode 100644 index 000000000..b099e09b4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -0,0 +1,127 @@ +package com.flxrs.dankchat.main.compose + +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.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.user.UserPopupStateParams +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.main.compose.sheets.MentionSheet +import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore + +@Composable +fun FullScreenSheetOverlay( + sheetState: FullScreenSheetState, + isLoggedIn: Boolean, + mentionViewModel: MentionComposeViewModel, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + onDismiss: () -> Unit, + onDismissReplies: () -> Unit, + onUserClick: (UserPopupStateParams) -> Unit, + onMessageLongClick: (MessageOptionsParams) -> Unit, + onEmoteClick: (List) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = sheetState !is FullScreenSheetState.Closed, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + val userClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, _ -> + onUserClick( + UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + ) + } + + when (sheetState) { + is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Mention -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = false, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, + onUserClick = userClickHandler, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + ) + }, + onEmoteClick = onEmoteClick + ) + } + is FullScreenSheetState.Whisper -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = true, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, + onUserClick = userClickHandler, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = false + ) + ) + }, + onEmoteClick = onEmoteClick + ) + } + is FullScreenSheetState.Replies -> { + RepliesSheet( + rootMessageId = sheetState.replyMessageId, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismissReplies, + onUserClick = userClickHandler, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + ) + } + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index fbbb3935d..be10f65eb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -7,9 +7,16 @@ 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.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.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications @@ -28,10 +35,14 @@ 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.Shape import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R -private sealed interface AppBarMenu { +sealed interface AppBarMenu { data object Main : AppBarMenu data object Account : AppBarMenu data object Channel : AppBarMenu @@ -55,6 +66,9 @@ fun MainAppBar( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, onReloadEmotes: () -> Unit, onReconnect: () -> Unit, onClearChat: () -> Unit, @@ -213,6 +227,27 @@ fun MainAppBar( AppBarMenu.Upload -> { SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.take_picture)) }, + onClick = { + onCaptureImage() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.record_video)) }, + onClick = { + onCaptureVideo() + currentMenu = null + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.choose_media)) }, + onClick = { + onChooseMedia() + currentMenu = null + } + ) } AppBarMenu.More -> { @@ -239,7 +274,7 @@ fun MainAppBar( } ) } - + null -> {} } } @@ -250,15 +285,164 @@ fun MainAppBar( ) } +@Composable +fun ToolbarOverflowMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isLoggedIn: Boolean, + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onOpenSettings: () -> Unit, + shape: Shape = MaterialTheme.shapes.medium, + offset: DpOffset = DpOffset.Zero, +) { + var currentMenu by remember { mutableStateOf(AppBarMenu.Main) } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { + onDismiss() + currentMenu = AppBarMenu.Main + }, + shape = shape, + offset = offset + ) { + 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 = "MenuTransition" + ) { menu -> + Column { + when (menu) { + AppBarMenu.Main -> { + if (!isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.login)) }, + onClick = { onLogin(); onDismiss() } + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.account)) }, + onClick = { currentMenu = AppBarMenu.Account } + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_channels)) }, + onClick = { onManageChannels(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.channel)) }, + onClick = { currentMenu = AppBarMenu.Channel } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.upload_media)) }, + onClick = { currentMenu = AppBarMenu.Upload } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.more)) }, + onClick = { currentMenu = AppBarMenu.More } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.settings)) }, + onClick = { onOpenSettings(); onDismiss() } + ) + } + AppBarMenu.Account -> { + SubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.relogin)) }, + onClick = { onRelogin(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.logout)) }, + onClick = { onLogout(); onDismiss() } + ) + } + AppBarMenu.Channel -> { + SubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.open_channel)) }, + onClick = { onOpenChannel(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_channel)) }, + onClick = { onRemoveChannel(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_channel)) }, + onClick = { onReportChannel(); onDismiss() } + ) + if (isLoggedIn) { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_channel)) }, + onClick = { onBlockChannel(); onDismiss() } + ) + } + } + AppBarMenu.Upload -> { + SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.take_picture)) }, + onClick = { onCaptureImage(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.record_video)) }, + onClick = { onCaptureVideo(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.choose_media)) }, + onClick = { onChooseMedia(); onDismiss() } + ) + } + AppBarMenu.More -> { + SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) + DropdownMenuItem( + text = { Text(stringResource(R.string.reload_emotes)) }, + onClick = { onReloadEmotes(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.reconnect)) }, + onClick = { onReconnect(); onDismiss() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.clear_chat)) }, + onClick = { onClearChat(); onDismiss() } + ) + } + null -> {} + } + } + } + } +} + @Composable private fun SubMenuHeader(title: String, onBack: () -> Unit) { DropdownMenuItem( - text = { + text = { Text( text = title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary - ) + ) }, leadingIcon = { Icon( @@ -269,4 +453,137 @@ private fun SubMenuHeader(title: String, onBack: () -> Unit) { }, onClick = onBack ) +} + +@Composable +fun InlineOverflowMenu( + isLoggedIn: Boolean, + isStreamActive: Boolean = false, + hasStreamData: Boolean = false, + onDismiss: () -> Unit, + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onManageChannels: () -> Unit, + onOpenChannel: () -> Unit, + onRemoveChannel: () -> Unit, + onReportChannel: () -> Unit, + onBlockChannel: () -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, + onReloadEmotes: () -> Unit, + onReconnect: () -> Unit, + onClearChat: () -> Unit, + onToggleStream: () -> Unit = {}, + onOpenSettings: () -> Unit, + initialMenu: AppBarMenu = AppBarMenu.Main, +) { + var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } + + 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 { + when (menu) { + AppBarMenu.Main -> { + if (!isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.login)) { onLogin(); onDismiss() } + } else { + InlineMenuItem(text = stringResource(R.string.account), hasSubMenu = true) { currentMenu = AppBarMenu.Account } + } + InlineMenuItem(text = stringResource(R.string.manage_channels)) { onManageChannels(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.channel), hasSubMenu = true) { currentMenu = AppBarMenu.Channel } + InlineMenuItem(text = stringResource(R.string.upload_media), hasSubMenu = true) { currentMenu = AppBarMenu.Upload } + InlineMenuItem(text = stringResource(R.string.more), hasSubMenu = true) { currentMenu = AppBarMenu.More } + InlineMenuItem(text = stringResource(R.string.settings)) { onOpenSettings(); onDismiss() } + } + AppBarMenu.Account -> { + InlineSubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.relogin)) { onRelogin(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.logout)) { onLogout(); onDismiss() } + } + AppBarMenu.Channel -> { + InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) + if (hasStreamData || isStreamActive) { + InlineMenuItem(text = stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) { onToggleStream(); onDismiss() } + } + InlineMenuItem(text = stringResource(R.string.open_channel)) { onOpenChannel(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.remove_channel)) { onRemoveChannel(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.report_channel)) { onReportChannel(); onDismiss() } + if (isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.block_channel)) { onBlockChannel(); onDismiss() } + } + } + AppBarMenu.Upload -> { + InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.take_picture)) { onCaptureImage(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.record_video)) { onCaptureVideo(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.choose_media)) { onChooseMedia(); onDismiss() } + } + AppBarMenu.More -> { + InlineSubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.reload_emotes)) { onReloadEmotes(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reconnect)) { onReconnect(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.clear_chat)) { onClearChat(); onDismiss() } + } + } + } + } +} + +@Composable +private fun InlineMenuItem(text: String, hasSubMenu: Boolean = false, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + if (hasSubMenu) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun InlineSubMenuHeader(title: String, onBack: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onBack) + .padding(horizontal = 12.dp, vertical = 12.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 + ) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt index 07e0a4f1c..f3370a537 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt @@ -2,6 +2,9 @@ package com.flxrs.dankchat.main.compose import com.flxrs.dankchat.main.MainEvent import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import org.koin.core.annotation.Single @@ -10,6 +13,13 @@ class MainEventBus { private val _events = Channel(Channel.BUFFERED) val events = _events.receiveAsFlow() + private val _isInPipMode = MutableStateFlow(false) + val isInPipMode: StateFlow = _isInPipMode.asStateFlow() + + fun setInPipMode(value: Boolean) { + _isInPipMode.value = value + } + suspend fun emitEvent(event: MainEvent) { _events.send(event) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 543489c81..ea7198dc0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -1,108 +1,114 @@ package com.flxrs.dankchat.main.compose -import android.content.ClipData +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.background 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.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.only 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.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.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +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.DisposableEffect 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.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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot -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.LocalFocusManager -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.max +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import android.app.Activity +import android.app.PictureInPictureParams +import android.os.Build +import android.util.Rational import androidx.navigation.compose.currentBackStackEntryAsState import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatComposable -import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.message.compose.MessageOptionsState -import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel import com.flxrs.dankchat.chat.user.UserPopupStateParams -import com.flxrs.dankchat.chat.user.compose.UserPopupDialog import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.main.compose.FullScreenSheetState -import com.flxrs.dankchat.main.compose.InputSheetState import com.flxrs.dankchat.main.MainEvent -import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog -import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog -import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog -import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog -import com.flxrs.dankchat.main.compose.sheets.EmoteMenuSheet -import com.flxrs.dankchat.main.compose.sheets.MentionSheet -import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.main.compose.sheets.EmoteMenu import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.preferences.components.DankBackground import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.window.core.layout.WindowSizeClass import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun MainScreen( navController: NavController, @@ -122,7 +128,7 @@ fun MainScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current - val clipboardManager = LocalClipboard.current + val density = LocalDensity.current // Scoped ViewModels - each handles one concern val mainScreenViewModel: MainScreenViewModel = koinViewModel() val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() @@ -130,6 +136,7 @@ fun MainScreen( val channelPagerViewModel: ChannelPagerViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val streamViewModel: StreamViewModel = koinViewModel() val mentionViewModel: com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel = koinViewModel() val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() val developerSettingsDataStore: DeveloperSettingsDataStore = koinInject() @@ -137,11 +144,117 @@ fun MainScreen( val mainEventBus: MainEventBus = koinInject() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val uriHandler = LocalUriHandler.current + val keyboardController = LocalSoftwareKeyboardController.current + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = developerSettingsDataStore.current()) val isRepeatedSendEnabled = developerSettings.repeatedSending + var keyboardHeightPx by remember(isLandscape) { + val persisted = if (isLandscape) preferenceStore.keyboardHeightLandscape else preferenceStore.keyboardHeightPortrait + mutableIntStateOf(persisted) + } + + 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 = androidx.compose.runtime.rememberUpdatedState(currentImeHeight) + val isImeVisible = WindowInsets.isImeVisible + + LaunchedEffect(isLandscape, density) { + snapshotFlow { + (imeTarget.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) + } + .debounce(300) + .collect { height -> + val minHeight = with(density) { 100.dp.toPx() } + if (height > minHeight) { + keyboardHeightPx = height + if (isLandscape) { + preferenceStore.keyboardHeightLandscape = height + } else { + preferenceStore.keyboardHeightPortrait = height + } + } + } + } + + // 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 { mutableStateOf(0f) } + + // Stream state + val currentStream by streamViewModel.currentStreamedChannel.collectAsStateWithLifecycle() + val hasStreamData by chatInputViewModel.hasStreamData.collectAsStateWithLifecycle() + var streamHeightDp by remember { mutableStateOf(0.dp) } + LaunchedEffect(currentStream) { + if (currentStream == null) streamHeightDp = 0.dp + } + + // PiP state — observe via lifecycle since onPause fires when entering PiP + val activity = context as? Activity + var isInPipMode by remember { mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = androidx.lifecycle.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() + ) + } + } + } + + // 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 (e: Exception) { + backProgress = 0f + } + } + var showAddChannelDialog by remember { mutableStateOf(false) } var showManageChannelsDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) } @@ -151,6 +264,14 @@ fun MainScreen( var userPopupParams by remember { mutableStateOf(null) } var messageOptionsParams by remember { mutableStateOf(null) } var emoteInfoEmotes by remember { mutableStateOf?>(null) } + var showRoomStateDialog by remember { mutableStateOf(false) } + var pendingUploadAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var isUploading by remember { mutableStateOf(false) } + var showLoginOutdatedDialog by remember { mutableStateOf(null) } + var showLoginExpiredDialog by remember { mutableStateOf(false) } + + val toolsSettingsDataStore: ToolsSettingsDataStore = koinInject() + val userStateRepository: UserStateRepository = koinInject() val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() @@ -159,12 +280,48 @@ fun MainScreen( mainEventBus.events.collect { event -> when (event) { is MainEvent.LogOutRequested -> showLogoutDialog = true - else -> Unit + is MainEvent.UploadLoading -> isUploading = true + is MainEvent.UploadSuccess -> { + isUploading = false + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.snackbar_image_uploaded, event.url), + actionLabel = context.getString(R.string.snackbar_paste), + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + chatInputViewModel.insertText(event.url) + } + } + is MainEvent.UploadFailed -> { + isUploading = false + val message = event.errorMessage?.let { context.getString(R.string.snackbar_upload_failed_cause, it) } + ?: context.getString(R.string.snackbar_upload_failed) + snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) + } + is MainEvent.LoginValidated -> { + snackbarHostState.showSnackbar( + message = context.getString(R.string.snackbar_login, event.username), + duration = SnackbarDuration.Short + ) + } + is MainEvent.LoginOutdated -> { + showLoginOutdatedDialog = event.username + } + MainEvent.LoginTokenInvalid -> { + showLoginExpiredDialog = true + } + MainEvent.LoginValidationFailed -> { + snackbarHostState.showSnackbar( + message = context.getString(R.string.oauth_verify_failed), + duration = SnackbarDuration.Short + ) + } + else -> Unit } } } - // Handle Login Result (previously in handleLoginRequest) + // Handle Login Result val navBackStackEntry = navController.currentBackStackEntry val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } LaunchedEffect(loginSuccess) { @@ -192,7 +349,8 @@ fun MainScreen( scope.launch { snackbarHostState.showSnackbar( message = state.message, - actionLabel = context.getString(R.string.snackbar_retry) + actionLabel = context.getString(R.string.snackbar_retry), + duration = SnackbarDuration.Long ) } } @@ -201,257 +359,131 @@ fun MainScreen( val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel - if (showAddChannelDialog) { - AddChannelDialog( - onDismiss = { showAddChannelDialog = false }, + MainScreenDialogs( + channelState = ChannelDialogState( + showAddChannel = showAddChannelDialog, + showManageChannels = showManageChannelsDialog, + showRemoveChannel = showRemoveChannelDialog, + showBlockChannel = showBlockChannelDialog, + showClearChat = showClearChatDialog, + showRoomState = showRoomStateDialog, + activeChannel = activeChannel, + roomStateChannel = inputState.activeChannel, + onDismissAddChannel = { showAddChannelDialog = false }, + onDismissManageChannels = { showManageChannelsDialog = false }, + onDismissRemoveChannel = { showRemoveChannelDialog = false }, + onDismissBlockChannel = { showBlockChannelDialog = false }, + onDismissClearChat = { showClearChatDialog = false }, + onDismissRoomState = { showRoomStateDialog = false }, onAddChannel = { channelManagementViewModel.addChannel(it) showAddChannelDialog = false - } - ) - } - - if (showManageChannelsDialog) { - val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() - ManageChannelsDialog( - channels = channels, - onApplyChanges = channelManagementViewModel::applyChanges, - onDismiss = { showManageChannelsDialog = false } - ) - } - - if (showLogoutDialog) { - AlertDialog( - onDismissRequest = { showLogoutDialog = false }, - title = { Text(stringResource(R.string.confirm_logout_title)) }, - text = { Text(stringResource(R.string.confirm_logout_message)) }, - confirmButton = { - TextButton( - onClick = { - onLogout() - showLogoutDialog = false - } - ) { - Text(stringResource(R.string.confirm_logout_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = { showLogoutDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (showRemoveChannelDialog && activeChannel != null) { - AlertDialog( - onDismissRequest = { showRemoveChannelDialog = false }, - title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, - text = { Text(stringResource(R.string.confirm_channel_removal_message_named, activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.removeChannel(activeChannel) - showRemoveChannelDialog = false - } - ) { - Text(stringResource(R.string.confirm_channel_removal_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = { showRemoveChannelDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (showBlockChannelDialog && activeChannel != null) { - AlertDialog( - onDismissRequest = { showBlockChannelDialog = false }, - title = { Text(stringResource(R.string.confirm_channel_block_title)) }, - text = { Text(stringResource(R.string.confirm_channel_block_message_named, activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.blockChannel(activeChannel) - showBlockChannelDialog = false - } - ) { - Text(stringResource(R.string.confirm_user_block_positive_button)) - } }, - dismissButton = { - TextButton(onClick = { showBlockChannelDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } + ), + authState = AuthDialogState( + showLogout = showLogoutDialog, + showLoginOutdated = showLoginOutdatedDialog != null, + showLoginExpired = showLoginExpiredDialog, + onDismissLogout = { showLogoutDialog = false }, + onDismissLoginOutdated = { showLoginOutdatedDialog = null }, + onDismissLoginExpired = { showLoginExpiredDialog = false }, + onLogout = onLogout, + onLogin = onLogin, + ), + messageState = MessageInteractionState( + messageOptionsParams = messageOptionsParams, + emoteInfoEmotes = emoteInfoEmotes, + userPopupParams = userPopupParams, + inputSheetState = inputSheetState, + onDismissMessageOptions = { messageOptionsParams = null }, + onDismissEmoteInfo = { emoteInfoEmotes = null }, + onDismissUserPopup = { userPopupParams = null }, + onOpenChannel = onOpenChannel, + onReportChannel = onReportChannel, + onOpenUrl = onOpenUrl, + ), + snackbarHostState = snackbarHostState, + ) - if (showClearChatDialog && activeChannel != null) { + // External hosting upload disclaimer dialog + if (pendingUploadAction != null) { + val uploadHost = remember { + runCatching { + java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host + }.getOrElse { "" } + } AlertDialog( - onDismissRequest = { showClearChatDialog = false }, - title = { Text(stringResource(R.string.clear_chat)) }, - text = { Text(stringResource(R.string.confirm_user_delete_message)) }, // Reuse message deletion text or find better one + onDismissRequest = { pendingUploadAction = null }, + title = { Text(stringResource(R.string.nuuls_upload_title)) }, + text = { Text(stringResource(R.string.external_upload_disclaimer, uploadHost)) }, confirmButton = { TextButton( onClick = { - channelManagementViewModel.clearChat(activeChannel) - showClearChatDialog = false + preferenceStore.hasExternalHostingAcknowledged = true + val action = pendingUploadAction + pendingUploadAction = null + action?.invoke() } ) { Text(stringResource(R.string.dialog_ok)) } }, dismissButton = { - TextButton(onClick = { showClearChatDialog = false }) { + TextButton(onClick = { pendingUploadAction = null }) { Text(stringResource(R.string.dialog_cancel)) } } ) } - messageOptionsParams?.let { params -> - val viewModel: MessageOptionsComposeViewModel = koinViewModel( - key = params.messageId, - parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } - ) - val state by viewModel.state.collectAsStateWithLifecycle() - (state as? MessageOptionsState.Found)?.let { s -> - MessageOptionsDialog( - messageId = s.messageId, - channel = params.channel?.value, - fullMessage = params.fullMessage, - canModerate = s.canModerate, - canReply = s.canReply, - canCopy = params.canCopy, - hasReplyThread = s.hasReplyThread, - onReply = { - chatInputViewModel.setReplying(true, s.messageId, s.replyName) - }, - onViewThread = { - sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) - }, - onCopy = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) - } - }, - onMoreActions = { - sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) - }, - onDelete = viewModel::deleteMessage, - onTimeout = viewModel::timeoutUser, - onBan = viewModel::banUser, - onUnban = viewModel::unbanUser, - onDismiss = { messageOptionsParams = null } - ) - } - } - - emoteInfoEmotes?.let { emotes -> - val viewModel: EmoteInfoComposeViewModel = koinViewModel( - key = emotes.joinToString { it.id }, - parameters = { parametersOf(emotes) } - ) - EmoteInfoDialog( - items = viewModel.items, - onUseEmote = { chatInputViewModel.insertText("$it ") }, - onCopyEmote = { /* TODO: copy to clipboard */ }, - onOpenLink = { onOpenUrl(it) }, - onDismiss = { emoteInfoEmotes = null } - ) - } - - userPopupParams?.let { params -> - val viewModel: UserPopupComposeViewModel = 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) }, - onBlockUser = viewModel::blockUser, - onUnblockUser = viewModel::unblockUser, - onDismiss = { userPopupParams = null }, - onMention = { name, _ -> - chatInputViewModel.insertText("@$name ") - }, - onWhisper = { name -> - chatInputViewModel.updateInputText("/w $name ") - }, - onOpenChannel = { _ -> onOpenChannel() }, - onReport = { _ -> - onReportChannel() - } - ) - } - - if (inputSheetState is InputSheetState.EmoteMenu) { - EmoteMenuSheet( - onDismiss = sheetNavigationViewModel::closeInputSheet, - onEmoteClick = { code, _ -> - chatInputViewModel.insertText("$code ") - sheetNavigationViewModel.closeInputSheet() - }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) - } - - if (inputSheetState is InputSheetState.MoreActions) { - val state = inputSheetState as InputSheetState.MoreActions - com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( - messageId = state.messageId, - fullMessage = state.fullMessage, - onCopyFullMessage = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) - } - }, - onCopyMessageId = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) - snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_id_copied)) - } - }, - onDismiss = sheetNavigationViewModel::closeInputSheet, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) - } - val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() + // Hide/show system bars when fullscreen toggles + val window = (LocalContext.current 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 { + // Restore system bars when leaving composition in fullscreen + controller.show(WindowInsetsCompat.Type.systemBars()) + } + } + val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() - val inputState by chatInputViewModel.uiState(sheetNavigationViewModel.fullScreenSheetState, mentionViewModel.currentTab).collectAsStateWithLifecycle() val composePagerState = rememberPagerState( initialPage = pagerState.currentPage, pageCount = { pagerState.channels.size } ) - val density = LocalDensity.current var inputHeightPx by remember { mutableIntStateOf(0) } var inputTopY by remember { mutableStateOf(0f) } var containerHeight by remember { mutableIntStateOf(0) } val inputHeightDp = with(density) { inputHeightPx.toDp() } val sheetBottomPadding = with(density) { (containerHeight - inputTopY).toDp() } - // Track keyboard visibility - clear focus only when keyboard is fully closed + // Clear focus when keyboard fully reaches the bottom, but not when + // switching to the emote menu. Prevents keyboard from reopening when + // returning from background. val focusManager = LocalFocusManager.current - val imeAnimationTarget = WindowInsets.imeAnimationTarget - val isKeyboardAtBottom = imeAnimationTarget.getBottom(density) == 0 - - LaunchedEffect(isKeyboardAtBottom) { - if (isKeyboardAtBottom) { - focusManager.clearFocus() - } + LaunchedEffect(Unit) { + snapshotFlow { imeHeightState.value == 0 && !inputState.isEmoteMenuOpen } + .distinctUntilChanged() + .collect { shouldClearFocus -> + if (shouldClearFocus) { + focusManager.clearFocus() + } + } } // Sync Compose pager with ViewModel state @@ -464,71 +496,42 @@ fun MainScreen( } } - // Update ViewModel when user swipes - LaunchedEffect(composePagerState.currentPage) { - if (composePagerState.currentPage != pagerState.currentPage) { - channelPagerViewModel.onPageChanged(composePagerState.currentPage) + // Update ViewModel when user swipes (use settledPage to avoid clearing + // unread/mention indicators for pages scrolled through during programmatic jumps) + LaunchedEffect(composePagerState.settledPage) { + if (composePagerState.settledPage != pagerState.currentPage) { + channelPagerViewModel.onPageChanged(composePagerState.settledPage) } } - val isKeyboardVisible = WindowInsets.isImeVisible || imeAnimationTarget.getBottom(density) > 0 - - val systemBarsPaddingModifier = if (isFullscreen) Modifier else Modifier.statusBarsPadding() - Box(modifier = Modifier .fillMaxSize() + .then(if (!isFullscreen && !isInPipMode) Modifier.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) else Modifier) .onGloballyPositioned { containerHeight = it.size.height } ) { - Scaffold( - modifier = modifier - .fillMaxSize() - .then(systemBarsPaddingModifier) - .imePadding(), - topBar = { - if (tabState.tabs.isEmpty()) { - return@Scaffold - } - - AnimatedVisibility( - visible = showAppBar && !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - MainAppBar( - isLoggedIn = isLoggedIn, - totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onAddChannel = { showAddChannelDialog = true }, - onOpenMentions = { sheetNavigationViewModel.openMentions() }, - onOpenWhispers = { sheetNavigationViewModel.openWhispers() }, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = { showLogoutDialog = true }, - onManageChannels = { showManageChannelsDialog = true }, - onOpenChannel = onOpenChannel, - onRemoveChannel = { showRemoveChannelDialog = true }, - onReportChannel = onReportChannel, - onBlockChannel = { showBlockChannelDialog = true }, - onReloadEmotes = { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() - }, - onReconnect = { - channelManagementViewModel.reconnect() - onReconnect() - }, - onClearChat = { showClearChatDialog = true }, - onOpenSettings = onNavigateToSettings - ) - } - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - bottomBar = { - AnimatedVisibility( - visible = showInputState && !isFullscreen, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - Box(modifier = Modifier.fillMaxWidth()) { + // 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 totalMenuHeight = targetMenuHeight + navBarHeightDp + + // Shared scaffold bottom padding calculation + val hasDialogWithInput = showAddChannelDialog || showRoomStateDialog || showManageChannelsDialog + 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 = { + Column(modifier = Modifier.fillMaxWidth()) { + if (showInputState) { ChatInputLayout( textFieldState = chatInputViewModel.textFieldState, inputState = inputState.inputState, @@ -536,228 +539,428 @@ fun MainScreen( canSend = inputState.canSend, showReplyOverlay = inputState.showReplyOverlay, replyName = inputState.replyName, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + helperText = inputState.helperText, + isUploading = isUploading, + isFullscreen = isFullscreen, + isModerator = userStateRepository.isModeratorInChannel(inputState.activeChannel), + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, onSend = chatInputViewModel::sendMessage, onLastMessageClick = chatInputViewModel::getLastMessage, - onEmoteClick = { sheetNavigationViewModel.openEmoteSheet() }, + onEmoteClick = { + if (!inputState.isEmoteMenuOpen) { + keyboardController?.hide() + chatInputViewModel.setEmoteMenuOpen(true) + } else { + keyboardController?.show() + } + }, onReplyDismiss = { chatInputViewModel.setReplying(false) }, + onToggleFullscreen = mainScreenViewModel::toggleFullscreen, + onToggleInput = mainScreenViewModel::toggleInput, + onToggleStream = { + activeChannel?.let { streamViewModel.toggleStream(it) } + }, + onChangeRoomState = { showRoomStateDialog = true }, modifier = Modifier.onGloballyPositioned { coordinates -> inputHeightPx = coordinates.size.height inputTopY = coordinates.positionInRoot().y } ) } + + // Sticky helper text + nav bar spacer when input is hidden + if (!showInputState) { + val helperText = inputState.helperText + if (!helperText.isNullOrEmpty()) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = helperText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .basicMarquee(), + textAlign = TextAlign.Start + ) + } + } + } } } - ) { paddingValues -> - // Main content of the chat (tabs, pager, empty state) - Box(modifier = Modifier.fillMaxSize()) { // This box gets the Scaffold's content padding - val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() - DankBackground(visible = showFullScreenLoading) - if (showFullScreenLoading) { - LinearProgressIndicator( + + // Shared floating toolbar + val floatingToolbar: @Composable (Modifier, Boolean, Boolean, Boolean) -> Unit = { toolbarModifier, visible, endAligned, showTabs -> + FloatingToolbar( + tabState = tabState, + composePagerState = composePagerState, + showAppBar = showAppBar && visible, + isFullscreen = isFullscreen, + isLoggedIn = isLoggedIn, + currentStream = currentStream, + hasStreamData = hasStreamData, + streamHeightDp = streamHeightDp, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, + onTabSelected = { index -> + channelTabViewModel.selectTab(index) + scope.launch { composePagerState.scrollToPage(index) } + }, + onTabLongClick = { index -> + channelTabViewModel.selectTab(index) + scope.launch { composePagerState.scrollToPage(index) } + }, + onAddChannel = { showAddChannelDialog = true }, + onOpenMentions = { sheetNavigationViewModel.openMentions() }, + onLogin = onLogin, + onRelogin = onRelogin, + onLogout = { showLogoutDialog = true }, + onManageChannels = { showManageChannelsDialog = true }, + onOpenChannel = onOpenChannel, + onRemoveChannel = { showRemoveChannelDialog = true }, + onReportChannel = onReportChannel, + onBlockChannel = { showBlockChannelDialog = true }, + onCaptureImage = { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else pendingUploadAction = onCaptureImage + }, + onCaptureVideo = { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else pendingUploadAction = onCaptureVideo + }, + onChooseMedia = { + if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else pendingUploadAction = onChooseMedia + }, + onReloadEmotes = { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + onReloadEmotes() + }, + onReconnect = { + channelManagementViewModel.reconnect() + onReconnect() + }, + onClearChat = { showClearChatDialog = true }, + onToggleStream = { + activeChannel?.let { streamViewModel.toggleStream(it) } + }, + onOpenSettings = onNavigateToSettings, + endAligned = endAligned, + showTabs = showTabs, + modifier = toolbarModifier, + ) + } + + // Shared emote menu layer + val emoteMenuLayer: @Composable (Modifier) -> Unit = { menuModifier -> + AnimatedVisibility( + visible = inputState.isEmoteMenuOpen, + enter = slideInVertically(animationSpec = tween(durationMillis = 140), initialOffsetY = { it }), + exit = slideOutVertically(animationSpec = tween(durationMillis = 140), targetOffsetY = { it }), + modifier = menuModifier + ) { + Box( modifier = Modifier .fillMaxWidth() - .padding(paddingValues) - ) - return@Scaffold - } - if (tabState.tabs.isEmpty() && !tabState.loading) { - EmptyStateContent( - isLoggedIn = isLoggedIn, - onAddChannel = { showAddChannelDialog = true }, - onLogin = onLogin, - onToggleAppBar = mainScreenViewModel::toggleAppBar, - onToggleFullscreen = mainScreenViewModel::toggleFullscreen, - onToggleInput = mainScreenViewModel::toggleInput, - modifier = Modifier.padding(paddingValues) - ) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + .height(totalMenuHeight) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + } + .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) { - if (tabState.loading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - AnimatedVisibility( - visible = !isFullscreen, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + EmoteMenu( + onEmoteClick = { code, _ -> + chatInputViewModel.insertText("$code ") + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + // Shared scaffold content (pager) + val scaffoldContent: @Composable (PaddingValues, Dp) -> Unit = { paddingValues, chatTopPadding -> + 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 = { showAddChannelDialog = true }, + onLogin = onLogin, + onToggleAppBar = mainScreenViewModel::toggleAppBar, + modifier = Modifier.padding(paddingValues) + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()) ) { - ChannelTabRow( - tabs = tabState.tabs, - selectedIndex = tabState.selectedIndex, - onTabSelected = { - channelTabViewModel.selectTab(it) - scope.launch { - composePagerState.animateScrollToPage(it) - } + HorizontalPager( + state = composePagerState, + modifier = Modifier.fillMaxSize(), + 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, _ -> + userPopupParams = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + messageOptionsParams = MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + }, + onEmoteClick = { emotes -> + emoteInfoEmotes = emotes + }, + onReplyClick = { replyMessageId, replyName -> + sheetNavigationViewModel.openReplies(replyMessageId, replyName) + }, + showInput = showInputState, + isFullscreen = isFullscreen, + hasHelperText = !inputState.helperText.isNullOrEmpty(), + onRecover = { + if (isFullscreen) mainScreenViewModel.toggleFullscreen() + if (!showInputState) mainScreenViewModel.toggleInput() + }, + contentPadding = PaddingValues( + top = chatTopPadding + 56.dp, + bottom = paddingValues.calculateBottomPadding() + ), + onScrollDirectionChanged = { } + ) } - ) - } - HorizontalPager( - state = composePagerState, - modifier = Modifier.fillMaxSize(), - 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, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - }, - onReplyClick = { replyMessageId, replyName -> - sheetNavigationViewModel.openReplies(replyMessageId, replyName) - } - ) } } } } } - } - // Fullscreen Overlay Sheets - androidx.compose.animation.AnimatedVisibility( - visible = fullScreenSheetState !is FullScreenSheetState.Closed, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = Modifier - .fillMaxSize() - .padding(bottom = sheetBottomPadding) - ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - when (val state = fullScreenSheetState) { - is FullScreenSheetState.Closed -> Unit - is FullScreenSheetState.Mention -> { - MentionSheet( - mentionViewModel = mentionViewModel, - initialisWhisperTab = false, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false + if (useWideSplitLayout) { + // --- Wide split layout: stream (left) | handle | chat (right) --- + var splitFraction by remember { mutableFloatStateOf(0.6f) } + var containerWidthPx by remember { mutableIntStateOf(0) } + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { containerWidthPx = it.size.width } + ) { + Row(modifier = Modifier.fillMaxSize()) { + // Left pane: Stream + Box(modifier = Modifier + .weight(splitFraction) + .fillMaxSize() + ) { + currentStream?.let { channel -> + StreamView( + channel = channel, + streamViewModel = streamViewModel, + fillPane = true, + onClose = { + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = Modifier.fillMaxSize() ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes } + } + + // Right pane: Chat + all overlays + Box(modifier = Modifier + .weight(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) }, + bottomBar = bottomBar, + ) { paddingValues -> + scaffoldContent(paddingValues, statusBarTop) + } + + val chatPaneWidthDp = with(density) { (containerWidthPx * (1f - splitFraction)).toInt().toDp() } + val showTabsInSplit = chatPaneWidthDp > 250.dp + + floatingToolbar( + Modifier.align(Alignment.TopCenter), + !isKeyboardVisible && !inputState.isEmoteMenuOpen, + false, + showTabsInSplit, ) - } - is FullScreenSheetState.Whisper -> { - MentionSheet( + + emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + + FullScreenSheetOverlay( + sheetState = fullScreenSheetState, + isLoggedIn = isLoggedIn, mentionViewModel = mentionViewModel, - initialisWhisperTab = true, appearanceSettingsDataStore = appearanceSettingsDataStore, onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false - ) + onDismissReplies = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - } + onUserClick = { userPopupParams = it }, + onMessageLongClick = { messageOptionsParams = it }, + onEmoteClick = { emoteInfoEmotes = it }, + modifier = Modifier.padding(bottom = sheetBottomPadding), ) + + if (showInputState && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp) + ) + } + } } - is FullScreenSheetState.Replies -> { - RepliesSheet( - rootMessageId = state.replyMessageId, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = { - sheetNavigationViewModel.closeFullScreenSheet() - chatInputViewModel.setReplying(false) - }, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) + // Draggable handle overlaid at the split edge + 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() } + ) + } + } else { + // --- Normal stacked layout (portrait / narrow-without-stream / PiP) --- + if (!isInPipMode) { + Scaffold( + modifier = modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), + contentWindowInsets = WindowInsets(0), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = bottomBar, + ) { paddingValues -> + val chatTopPadding = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamHeightDp) + scaffoldContent(paddingValues, chatTopPadding) } + } // end !isInPipMode + + // Stream View layer + currentStream?.let { channel -> + StreamView( + channel = channel, + streamViewModel = streamViewModel, + isInPipMode = isInPipMode, + onClose = { + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = if (isInPipMode) { + Modifier.fillMaxSize() + } else { + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + streamHeightDp = with(density) { coordinates.size.height.toDp() } + } + } + ) } - } - } - if (showInputState && !isFullscreen && isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp) - ) + // Status bar scrim (hidden in fullscreen and PiP) + if (!isFullscreen && !isInPipMode) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .background(if (currentStream != null) Color.Black else MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) + ) + } + + // Floating Toolbars - collapsible tabs (expand on swipe) + actions + if (!isInPipMode) floatingToolbar( + Modifier.align(Alignment.TopCenter), + !isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen), + true, + true, + ) + + // Emote Menu Layer - slides up/down independently of keyboard + // Fast tween to match system keyboard animation speed + if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + + // Fullscreen Overlay Sheets + if (!isInPipMode) FullScreenSheetOverlay( + sheetState = fullScreenSheetState, + isLoggedIn = isLoggedIn, + mentionViewModel = mentionViewModel, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onDismissReplies = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = { userPopupParams = it }, + onMessageLongClick = { messageOptionsParams = it }, + onEmoteClick = { emoteInfoEmotes = it }, + modifier = Modifier.padding(bottom = sheetBottomPadding), + ) + + if (!isInPipMode && showInputState && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp) + ) + } + } } } -} \ No newline at end of file + diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt new file mode 100644 index 000000000..c04cdf77e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -0,0 +1,374 @@ +package com.flxrs.dankchat.main.compose + +import android.content.ClipData +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +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.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.message.compose.MessageOptionsState +import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel +import com.flxrs.dankchat.chat.user.UserPopupStateParams +import com.flxrs.dankchat.chat.user.compose.UserPopupDialog +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog +import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog +import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog +import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Stable +data class ChannelDialogState( + val showAddChannel: Boolean, + val showManageChannels: Boolean, + val showRemoveChannel: Boolean, + val showBlockChannel: Boolean, + val showClearChat: Boolean, + val showRoomState: Boolean, + val activeChannel: UserName?, + val roomStateChannel: UserName?, + val onDismissAddChannel: () -> Unit, + val onDismissManageChannels: () -> Unit, + val onDismissRemoveChannel: () -> Unit, + val onDismissBlockChannel: () -> Unit, + val onDismissClearChat: () -> Unit, + val onDismissRoomState: () -> Unit, + val onAddChannel: (UserName) -> Unit, +) + +@Stable +data class AuthDialogState( + val showLogout: Boolean, + val showLoginOutdated: Boolean, + val showLoginExpired: Boolean, + val onDismissLogout: () -> Unit, + val onDismissLoginOutdated: () -> Unit, + val onDismissLoginExpired: () -> Unit, + val onLogout: () -> Unit, + val onLogin: () -> Unit, +) + +@Stable +data class MessageInteractionState( + val messageOptionsParams: MessageOptionsParams?, + val emoteInfoEmotes: List?, + val userPopupParams: UserPopupStateParams?, + val inputSheetState: InputSheetState, + val onDismissMessageOptions: () -> Unit, + val onDismissEmoteInfo: () -> Unit, + val onDismissUserPopup: () -> Unit, + val onOpenChannel: () -> Unit, + val onReportChannel: () -> Unit, + val onOpenUrl: (String) -> Unit, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreenDialogs( + channelState: ChannelDialogState, + authState: AuthDialogState, + messageState: MessageInteractionState, + snackbarHostState: SnackbarHostState, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() + val chatInputViewModel: ChatInputViewModel = koinViewModel() + val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val channelRepository: ChannelRepository = koinInject() + + // region Channel dialogs + + if (channelState.showAddChannel) { + AddChannelDialog( + onDismiss = channelState.onDismissAddChannel, + onAddChannel = channelState.onAddChannel + ) + } + + if (channelState.showManageChannels) { + val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() + ManageChannelsDialog( + channels = channels, + onApplyChanges = channelManagementViewModel::applyChanges, + onDismiss = channelState.onDismissManageChannels + ) + } + + if (channelState.showRoomState && channelState.roomStateChannel != null) { + RoomStateDialog( + roomState = channelRepository.getRoomState(channelState.roomStateChannel), + onSendCommand = { command -> + chatInputViewModel.trySendMessageOrCommand(command) + }, + onDismiss = channelState.onDismissRoomState + ) + } + + if (channelState.showRemoveChannel && channelState.activeChannel != null) { + AlertDialog( + onDismissRequest = channelState.onDismissRemoveChannel, + title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, + text = { Text(stringResource(R.string.confirm_channel_removal_message_named, channelState.activeChannel)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.removeChannel(channelState.activeChannel) + channelState.onDismissRemoveChannel() + } + ) { + Text(stringResource(R.string.confirm_channel_removal_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = channelState.onDismissRemoveChannel) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (channelState.showBlockChannel && channelState.activeChannel != null) { + AlertDialog( + onDismissRequest = channelState.onDismissBlockChannel, + title = { Text(stringResource(R.string.confirm_channel_block_title)) }, + text = { Text(stringResource(R.string.confirm_channel_block_message_named, channelState.activeChannel)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.blockChannel(channelState.activeChannel) + channelState.onDismissBlockChannel() + } + ) { + Text(stringResource(R.string.confirm_user_block_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = channelState.onDismissBlockChannel) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (channelState.showClearChat && channelState.activeChannel != null) { + AlertDialog( + onDismissRequest = channelState.onDismissClearChat, + title = { Text(stringResource(R.string.clear_chat)) }, + text = { Text(stringResource(R.string.confirm_user_delete_message)) }, + confirmButton = { + TextButton( + onClick = { + channelManagementViewModel.clearChat(channelState.activeChannel) + channelState.onDismissClearChat() + } + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = channelState.onDismissClearChat) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + // endregion + + // region Auth dialogs + + if (authState.showLogout) { + AlertDialog( + onDismissRequest = authState.onDismissLogout, + title = { Text(stringResource(R.string.confirm_logout_title)) }, + text = { Text(stringResource(R.string.confirm_logout_message)) }, + confirmButton = { + TextButton( + onClick = { + authState.onLogout() + authState.onDismissLogout() + } + ) { + Text(stringResource(R.string.confirm_logout_positive_button)) + } + }, + dismissButton = { + TextButton(onClick = authState.onDismissLogout) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (authState.showLoginOutdated) { + AlertDialog( + onDismissRequest = authState.onDismissLoginOutdated, + title = { Text(stringResource(R.string.login_outdated_title)) }, + text = { Text(stringResource(R.string.login_outdated_message)) }, + confirmButton = { + TextButton(onClick = { + authState.onDismissLoginOutdated() + authState.onLogin() + }) { + Text(stringResource(R.string.oauth_expired_login_again)) + } + }, + dismissButton = { + TextButton(onClick = authState.onDismissLoginOutdated) { + Text(stringResource(R.string.dialog_dismiss)) + } + } + ) + } + + if (authState.showLoginExpired) { + AlertDialog( + onDismissRequest = authState.onDismissLoginExpired, + title = { Text(stringResource(R.string.oauth_expired_title)) }, + text = { Text(stringResource(R.string.oauth_expired_message)) }, + confirmButton = { + TextButton(onClick = { + authState.onDismissLoginExpired() + authState.onLogin() + }) { + Text(stringResource(R.string.oauth_expired_login_again)) + } + }, + dismissButton = { + TextButton(onClick = authState.onDismissLoginExpired) { + Text(stringResource(R.string.dialog_dismiss)) + } + } + ) + } + + // endregion + + // region Message interactions + + messageState.messageOptionsParams?.let { params -> + val viewModel: MessageOptionsComposeViewModel = koinViewModel( + key = params.messageId, + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } + ) + val state by viewModel.state.collectAsStateWithLifecycle() + (state as? MessageOptionsState.Found)?.let { s -> + MessageOptionsDialog( + messageId = s.messageId, + channel = params.channel?.value, + fullMessage = params.fullMessage, + canModerate = s.canModerate, + canReply = s.canReply, + canCopy = params.canCopy, + hasReplyThread = s.hasReplyThread, + onReply = { + chatInputViewModel.setReplying(true, s.messageId, s.replyName) + }, + onViewThread = { + sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) + }, + onCopy = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onMoreActions = { + sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) + }, + onDelete = viewModel::deleteMessage, + onTimeout = viewModel::timeoutUser, + onBan = viewModel::banUser, + onUnban = viewModel::unbanUser, + onDismiss = messageState.onDismissMessageOptions + ) + } + } + + messageState.emoteInfoEmotes?.let { emotes -> + val viewModel: EmoteInfoComposeViewModel = koinViewModel( + key = emotes.joinToString { it.id }, + parameters = { parametersOf(emotes) } + ) + EmoteInfoDialog( + items = viewModel.items, + onUseEmote = { chatInputViewModel.insertText("$it ") }, + onCopyEmote = { /* TODO: copy to clipboard */ }, + onOpenLink = { messageState.onOpenUrl(it) }, + onDismiss = messageState.onDismissEmoteInfo + ) + } + + messageState.userPopupParams?.let { params -> + val viewModel: UserPopupComposeViewModel = 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) }, + onBlockUser = viewModel::blockUser, + onUnblockUser = viewModel::unblockUser, + onDismiss = messageState.onDismissUserPopup, + onMention = { name, _ -> + chatInputViewModel.insertText("@$name ") + }, + onWhisper = { name -> + chatInputViewModel.updateInputText("/w $name ") + }, + onOpenChannel = { _ -> messageState.onOpenChannel() }, + onReport = { _ -> + messageState.onReportChannel() + } + ) + } + + val inputSheet = messageState.inputSheetState + if (inputSheet is InputSheetState.MoreActions) { + com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( + messageId = inputSheet.messageId, + fullMessage = inputSheet.fullMessage, + onCopyFullMessage = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) + } + }, + onCopyMessageId = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_id_copied)) + } + }, + onDismiss = sheetNavigationViewModel::closeInputSheet, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) + } + + // endregion +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt new file mode 100644 index 000000000..e92300adc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -0,0 +1,169 @@ +package com.flxrs.dankchat.main.compose + +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName + +@Composable +fun StreamView( + channel: UserName, + streamViewModel: StreamViewModel, + isInPipMode: Boolean = false, + fillPane: Boolean = false, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + // 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) } + val webView = remember { + streamViewModel.getOrCreateWebView().also { wv -> + wv.setBackgroundColor(android.graphics.Color.TRANSPARENT) + wv.webViewClient = StreamComposeWebViewClient( + onPageFinished = { isPageLoaded = true } + ) + } + } + + // 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.currentStreamedChannel.value == null) { + streamViewModel.destroyWebView(webView) + } + } + } + + Box( + modifier = modifier + .then(if (isInPipMode || fillPane) Modifier else Modifier.statusBarsPadding()) + .fillMaxWidth() + .background(Color.Black) + ) { + 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 + } + webView + }, + update = { _ -> + // For subsequent opens: load URL while attached + streamViewModel.setStream(channel, webView) + }, + modifier = webViewModifier + ) + } else { + Box(modifier = webViewModifier) + } + + if (!isInPipMode) { + IconButton( + onClick = onClose, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(8.dp) + .size(36.dp) + .background( + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f), + shape = CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +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/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt new file mode 100644 index 000000000..fa95b34b6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt @@ -0,0 +1,88 @@ +package com.flxrs.dankchat.main.compose + +import android.annotation.SuppressLint +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository +import com.flxrs.dankchat.main.stream.StreamWebView +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.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class StreamViewModel( + application: Application, + private val streamDataRepository: StreamDataRepository, + private val streamsSettingsDataStore: StreamsSettingsDataStore, +) : AndroidViewModel(application) { + + private val _currentStreamedChannel = MutableStateFlow(null) + val currentStreamedChannel: StateFlow = _currentStreamedChannel.asStateFlow() + + val shouldEnablePipAutoMode: StateFlow = combine( + currentStreamedChannel, + streamsSettingsDataStore.pipEnabled, + ) { currentStream, pipEnabled -> + currentStream != null && pipEnabled + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + 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 } + } + + fun closeStream() { + _currentStreamedChannel.value = null + } + + override fun onCleared() { + cachedWebView?.destroy() + cachedWebView = null + lastStreamedChannel = null + super.onCleared() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt index 751c33e13..633768799 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt @@ -39,8 +39,9 @@ fun AddChannelDialog( singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { - if (channelName.isNotBlank()) { - onAddChannel(UserName(channelName)) + val trimmed = channelName.trim() + if (trimmed.isNotBlank()) { + onAddChannel(UserName(trimmed)) onDismiss() } }), @@ -50,7 +51,7 @@ fun AddChannelDialog( confirmButton = { TextButton( onClick = { - onAddChannel(UserName(channelName)) + onAddChannel(UserName(channelName.trim())) onDismiss() }, enabled = channelName.isNotBlank() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt index 28b265ae3..29f372ff4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt @@ -16,12 +16,12 @@ 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.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.rememberModalBottomSheetState import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -53,7 +53,10 @@ fun EmoteInfoDialog( val scope = rememberCoroutineScope() val pagerState = rememberPagerState(pageCount = { items.size }) - ModalBottomSheet(onDismissRequest = onDismiss) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { Column(modifier = Modifier.fillMaxWidth()) { if (items.size > 1) { PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { @@ -103,19 +106,19 @@ private fun EmoteInfoContent( onOpenLink: () -> Unit, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.Top ) { AsyncImage( model = item.imageUrl, contentDescription = stringResource(R.string.emote_sheet_image_description), - modifier = Modifier.size(128.dp) + modifier = Modifier.size(96.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) { @@ -144,19 +147,14 @@ private fun EmoteInfoContent( textAlign = TextAlign.Center ) } - if (item.isZeroWidth) { - Text( - text = stringResource(R.string.emote_sheet_zero_width_emote), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - } + Text( + text = if (item.isZeroWidth) stringResource(R.string.emote_sheet_zero_width_emote) else "", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) } } - Spacer(modifier = Modifier.height(24.dp)) - HorizontalDivider() - ListItem( headlineContent = { Text(stringResource(R.string.emote_sheet_use)) }, leadingContent = { Icon(Icons.Default.InsertEmoticon, contentDescription = null) }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt index 4b05412d1..d1434e7b2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -62,13 +62,9 @@ fun ManageChannelsDialog( val lazyListState = rememberLazyListState() val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> - // Adjust indices because of the header item at index 0 - val fromIndex = from.index - 1 - val toIndex = to.index - 1 - - if (fromIndex in localChannels.indices && toIndex in localChannels.indices) { + if (from.index in localChannels.indices && to.index in localChannels.indices) { localChannels.apply { - add(toIndex, removeAt(fromIndex)) + add(to.index, removeAt(from.index)) } } } @@ -85,15 +81,7 @@ fun ManageChannelsDialog( .padding(bottom = 32.dp), state = lazyListState ) { - item { - Text( - text = stringResource(R.string.manage_channels), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(16.dp) - ) - } - - items(localChannels, key = { it.channel.value }) { channelWithRename -> + itemsIndexed(localChannels, key = { _, it -> it.channel.value }) { index, channelWithRename -> ReorderableItem(reorderableState, key = channelWithRename.channel.value) { isDragging -> val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) @@ -111,10 +99,12 @@ fun ManageChannelsDialog( onEdit = { channelToEdit = channelWithRename }, onDelete = { channelToDelete = channelWithRename.channel } ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) + if (index < localChannels.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt index d07914f67..3d1d0738f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt @@ -16,7 +16,6 @@ import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -24,6 +23,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -65,7 +65,10 @@ fun MessageOptionsDialog( var showBanDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } - ModalBottomSheet(onDismissRequest = onDismiss) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt new file mode 100644 index 000000000..6b551f188 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt @@ -0,0 +1,190 @@ +package com.flxrs.dankchat.main.compose.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +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.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.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.twitch.message.RoomState + +private enum class ParameterDialogType { + SLOW_MODE, + FOLLOWER_MODE +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun RoomStateDialog( + roomState: RoomState?, + onSendCommand: (String) -> Unit, + onDismiss: () -> Unit, +) { + var parameterDialog by remember { mutableStateOf(null) } + + parameterDialog?.let { type -> + val (title, hint, defaultValue, commandPrefix) = when (type) { + ParameterDialogType.SLOW_MODE -> listOf( + R.string.room_state_slow_mode, + R.string.seconds, + "30", + "/slow" + ) + ParameterDialogType.FOLLOWER_MODE -> listOf( + R.string.room_state_follower_only, + R.string.minutes, + "10", + "/followers" + ) + } + + var inputValue by remember { mutableStateOf(defaultValue as String) } + + AlertDialog( + onDismissRequest = { parameterDialog = null }, + title = { Text(stringResource(title as Int)) }, + text = { + OutlinedTextField( + value = inputValue, + onValueChange = { inputValue = it }, + label = { Text(stringResource(hint as Int)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { + onSendCommand("$commandPrefix $inputValue") + parameterDialog = null + onDismiss() + }) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { parameterDialog = null }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + 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 = { + if (isSlowMode) { + onSendCommand("/slowoff") + onDismiss() + } else { + parameterDialog = ParameterDialogType.SLOW_MODE + } + }, + label = { + val label = stringResource(R.string.room_state_slow_mode) + Text(if (isSlowMode && slowModeWaitTime != null) "$label (${slowModeWaitTime}s)" 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 = { + if (isFollowerOnly) { + onSendCommand("/followersoff") + onDismiss() + } else { + parameterDialog = ParameterDialogType.FOLLOWER_MODE + } + }, + label = { + val label = stringResource(R.string.room_state_follower_only) + Text(if (isFollowerOnly && followerDuration != null && followerDuration > 0) "$label (${followerDuration}m)" else label) + }, + leadingIcon = if (isFollowerOnly) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null, + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt new file mode 100644 index 000000000..e10cee754 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt @@ -0,0 +1,163 @@ +package com.flxrs.dankchat.main.compose.sheets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.ui.platform.LocalDensity +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.emotemenu.EmoteItem +import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab +import com.flxrs.dankchat.main.compose.EmoteMenuViewModel +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Surface +import com.flxrs.dankchat.preferences.components.DankBackground + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmoteMenu( + onEmoteClick: (String, String) -> Unit, + viewModel: EmoteMenuViewModel = koinViewModel(), + modifier: Modifier = Modifier +) { + val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { tabItems.size } + ) + + 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) + } + ) + } + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1 + ) { page -> + val tab = tabItems[page] + 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) // Offset below logo + ) + } + } else { + val navBarBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current) + val navBarBottomDp = with(LocalDensity.current) { navBarBottom.toDp() } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 40.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.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/main/compose/sheets/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt index e4a557843..15c75c6fe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt @@ -84,7 +84,7 @@ fun EmoteMenuSheet( ) { page -> val items = tabItems[page].items LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 48.dp), + columns = GridCells.Adaptive(minSize = 40.dp), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index e0e6d4cd5..0d7d4b7ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.main.compose.sheets -import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -14,7 +13,6 @@ 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.mutableFloatStateOf import androidx.compose.runtime.remember @@ -22,11 +20,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatScreen -import com.flxrs.dankchat.chat.replies.RepliesUiState +import com.flxrs.dankchat.chat.replies.compose.RepliesComposable import com.flxrs.dankchat.chat.replies.compose.RepliesComposeViewModel import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.CancellationException @@ -81,26 +77,14 @@ fun RepliesSheet( translationY = backProgress * 100f } ) { paddingValues -> - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) - - ChatScreen( - messages = when (val state = uiState) { - is RepliesUiState.Found -> state.items - else -> emptyList() - }, - fontSize = appearanceSettings.fontSize.toFloat(), - modifier = Modifier.padding(paddingValues).fillMaxSize(), + RepliesComposable( + repliesViewModel = viewModel, + appearanceSettingsDataStore = appearanceSettingsDataStore, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, - onEmoteClick = { /* no-op */ } + onNotFound = onDismiss, + modifier = Modifier.padding(paddingValues).fillMaxSize(), ) - - if (uiState is RepliesUiState.NotFound) { - androidx.compose.runtime.LaunchedEffect(Unit) { - onDismiss() - } - } } 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 5172df84d..ab4d719b5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -81,6 +81,14 @@ 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) } @@ -218,6 +226,8 @@ class DankChatPreferenceStore( 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" 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 7fb59b101..725ee8da3 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 @@ -38,6 +38,51 @@ 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 @@ -47,6 +92,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() diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index e1af229f6..c554b23af 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -414,4 +414,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ VIP Gründer Abonnent + Wähle benutzerdefinierte Highlight Farbe + Standard + Farbe wählen diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index bc5064543..b2b118f8e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -407,4 +407,7 @@ VIP Founder Subscriber + Pick custom highlight color + Default + Choose Color diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 406307a3a..aad74fc7f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -322,10 +322,12 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Usuarios Lista Negra de Usuarios Twitch + Emblemas Deshacer Elemento eliminado Usuario %1$s desbloqueado Error al desbloquear el usuario %1$s + Emblema Error al bloquear el usuario %1$s Tu usuario Suscripciones y Eventos @@ -338,6 +340,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Crea notificaciones y destaca mensajes basados en ciertos patrones. Crea notificaciones y destaca los mensajes de ciertos usuarios. Desactiva las notificaciones y los destacados de ciertos usuarios (ej. bots). + Crea notificaciones y destaca los mensajes de los usuarios en función de los emblemas. Ignorar mensajes basados en ciertos patrones. Ignorar mensajes de ciertos usuarios. Gestiona los usuarios bloqueados de Twitch. @@ -402,4 +405,16 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Mostrar categoría del stream Muestra también la categoría del stream Alternar entrada + Streamer + Administrador + Staff + Moderador + Moderador principal + Verificado + VIP + Fundador + Suscriptor + Elegir color de resaltado personalizado + Predeterminado + Elegir color diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 49c94ae01..098521678 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -211,6 +211,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Lukee vain viestin ääneen Lukee käyttäjän ja viestin Viestin muoto + Ohittaa URL-osoitteet TTS:ssä TTS Ruudulliset viivat Erota kukin rivi erillaisella taustan kirkkaudella diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 645b32330..1c383a6bf 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -320,10 +320,12 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Użytkownicy Użytkownicy na Czarnej liście Twitch + Odznaki Cofnij Element został usunięty Odblokowano użytkownika %1$s Nie udało się odblokować użytkownika %1$s + Odznaka Nie udało się zablokować użytkownika %1$s Twoja nazwa użytkownika Subskrypcje i Wydarzenia @@ -336,6 +338,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Tworzy powiadomienia i wyróżnia wiadomości na podstawie określonych wzorów. Tworzy powiadomienia i wyróżnia wiadomości od określonych użytkowników. Wyłącz powiadomienia i wyróżnienia od określonych użytkowników (np. botów) + Utwórz powiadomienia i wyróżnienia wiadomości od użytkowników na podstawie odznak. Ignoruj wiadomości na podstawie określonych wzorów. Ignoruj wiadomości od określonych użytkowników. Zarządzaj zablokowanymi użytkownikami. @@ -383,6 +386,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pomniejsz Powiększ Wróć + Wspólny Czat Na żywo z %1$d widzem przez %2$s Na żywo z %1$d widzami przez %2$s @@ -395,4 +399,26 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %d miesięcy %d miesięcy + Licencje Open Source + + Na żywo z %1$d widzem w %2$s od %3$s + Na żywo z %1$d widzami w %2$s od %3$s + Na żywo z %1$d widzami w %2$s od %3$s + Na żywo z %1$d widzami w %2$s od %3$s + + Pokaż kategorię transmisji + Wyświetlaj również kategorię transmisji + Przełącz pole wprowadzania + Nadawca + Admin + Personel + Moderator + Główny Moderator + Zweryfikowane + VIP + Założyciel + Subskrybent + Wybierz niestandardowy kolor podświetlenia + Domyślny + Wybierz Kolor diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 04b5ea691..e91d07899 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -322,10 +322,12 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Kullanıcılar Kara Listeli Kullanıcılar Twitch + Rozetler Geri al Öge kaldırıldı %1$s kullanıcısının engeli kaldırıldı %1$s kullanıcısının engeli kaldırılamadı + Rozet %1$s kullanıcısı engellenemedi Kullanıcı adınız Abonelikler ile Etkinlikler @@ -338,6 +340,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Belli şablonlara göre bildirimler oluşturup mesajları öne çıkarır. Belli kullanıcılardan bildirimler oluşturup mesajlar öne çıkarır. Belli kullanıcılardan (örneğin botlardan) bildirimler ile öne çıkarmaları etkisizleştirir. + Rozetlere göre kullanıcıların mesajlarından bildirimler ve vurgular oluştur. Belli şablonlara göre mesajları yoksayar. Belli kullanıcılardan gelen mesajları yok say. Engellenen Twitch kullanıcılarını yönet. @@ -402,4 +405,16 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Yayın kategorisini göster Yayın kategorisini de göster Girişi Değiştir + Yayıncı + Yönetici + Ekip + Moderatör + Baş moderatör + Doğrulandı + VIP + Kurucu + Abone + Özel vurgu rengi seç + Varsayılan + Renk Seç diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 596550ca3..d68c7e792 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,8 @@ FeelsDankMan DankChat running in the background Open the emote menu + Close the emote menu + No recent emotes Emotes Login to Twitch.tv Start chatting @@ -126,6 +128,17 @@ 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 + Fullscreen + Exit fullscreen + Hide input +Room state + Emote only + Subscriber only + Slow mode + Unique chat (R9K) + Follower only Account Login again Logout diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b94dcd0af..e7e5102ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,39 +1,40 @@ [versions] -kotlin = "2.3.0" +kotlin = "2.3.10" coroutines = "1.10.2" -serialization = "1.9.0" +serialization = "1.10.0" datetime = "0.7.1-0.6.x-compat" immutable = "0.4.0" -ktor = "3.3.3" +ktor = "3.4.0" coil = "3.3.0" okhttp = "5.3.2" -ksp = "2.3.4" +ksp = "2.3.5" koin = "4.1.1" koin-annotations = "2.3.1" about-libraries = "13.2.1" androidGradlePlugin = "8.13.2" androidDesugarLibs = "2.1.5" -androidxActivity = "1.12.2" +androidxActivity = "1.12.4" androidxBrowser = "1.9.0" androidxConstraintLayout = "2.2.1" androidxCore = "1.17.0" androidxEmoji2 = "1.6.0" androidxExif = "1.4.2" androidxFragment = "1.8.9" -androidxTransition = "1.6.0" +androidxTransition = "1.7.0" androidxLifecycle = "2.10.0" androidxMedia = "1.7.1" -androidxNavigation = "2.9.5" +androidxNavigation = "2.9.7" androidxRecyclerview = "1.4.0" androidxViewpager2 = "1.1.0" androidxRoom = "2.8.4" androidxWebkit = "1.15.0" androidxDataStore = "1.2.0" -compose = "1.10.0" +compose = "1.10.3" compose-icons = "1.7.8" -compose-materia3 = "1.5.0-alpha11" -compose-unstyled = "1.49.3" +compose-materia3 = "1.5.0-alpha14" +compose-material3-adaptive = "1.2.0" +compose-unstyled = "1.49.6" material = "1.13.0" flexBox = "3.0.0" autoLinkText = "2.0.2" @@ -43,8 +44,8 @@ processPhoenix = "3.0.0" colorPicker = "3.1.0" reorderable = "2.4.3" -junit = "6.0.1" -mockk = "1.14.7" +junit = "6.0.2" +mockk = "1.14.9" [libraries] android-desugar-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "androidDesugarLibs" } @@ -96,6 +97,7 @@ 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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..61285a659d17295f1de7c53e24fdf13ad755c379 100644 GIT binary patch delta 37988 zcmX6@V|bli*Gyxa*tTsajcwbu)95rhv2ELp-Pl&+oY+>=r1|>1-;aI&zOTJz_N+B) z9#P&Gv}#H23%q7=tU;GV-|;-tohf=I~hj-MKNe%d3eh&|2-wPA>Ee zMNrvEdyVSI`lvDy^z`gwVkn}LK|GSq#|4JnxoIRO@sx!|eY?;bc>3*K_2AXEE_$R{ zzCVzv3UKiDb5r_h5D*ZZ5Gi+pL@8*f(!iG1x>gdb9^Yv>r}mh(L3OFb;);CzTapwz zf*i|?OK0?9kw_P?-0dFJtLi=zod6Wn?%aD|P%jYTC%iX)k0Vd>Vbm^Cr+C9_<`m40 zM^@Lgu3F}@42za*z}FZG8H@~yghPxY23DilF(fmO%LhdnWy_>0YdUU%{5;eNLSXXl zUnx6g^xx`|70?R~2k4=9*}u3!x!&jn08l8EddKmclPN&pp#^~95+?=Q%XO*?SHv{B znC+V^K--hOSb9;Y5YgB1Ej3fir@e4s?^di<$}xQHuJ$q9?N!Bv!`7I6)5sbU#Lw1u^S7sb(g%zNh3L5MJQsdwRb zgNo>59Bh1jM9C5avMI{R90Kw05r4looJRr#4qh*PUNQS31%o#@dU69Nb{wvnpC=mn zcY`1@hbV^re0-c7@lN8bd570AB1N~=VPVbKaVf?8DYvLWmcdQ+38(I$J+%Pl`B!V> zZq%>Y`%Vt>v8{!@1PO9E2 z9VBjQeL_arEHz!fGJkWBR?n$iC=29KvaHy#Um6^Nh-;kO0n9to04%I0VDXCW&Btps3x;+T81snc7-CrYOO1 zBwrOxlYveR3vF?GBNGFK(sl#BMS^d}9ZAd|AuI~qjv&_lj;$rIO{nGxgiPL|9DKXC z#tSNLxrea`Z|G>h1HZmhBsPlM5D1TDN+yJ5N^b0-)-S?aZIOvmD<@f5T70wrgb#~L za_t)z{ST{ujY^K=AR!<&q5jdEF{PgoCnXw_80e&eDTWr54k^=6hJ}7>BqX-6Sfa*O zwVX*76(t8X%1|D_zS(`{=Gwk?X;d>jj(W%YDu$UVi3$8JI>{GEPR3=|qu_0AlW$|~ z?fv{xK-v#cOEGqPc6)1e7piu!+LzeYWUcE(>7pCF>ncpr8O-(Z6US0#5K{>2aOl)rz?K{}OE1UoTv+&+5Y0HkKiPPx_ zSUMqKtF!#T&Cp4YDQDIn9b;#M?Zx0qqt5TlH)Vr5!Xg@RQo&-HV|Ik@n=3Oamu2lh z49_-ckP&y{-UyE%0O8+5(Hp5n`BHJk0y(H^HQ+mE^&zM2ap4%A~gIc#u*(J)5 zFWKk7)#JdESw8EXxkA^MIWExbt1a0@>sH2q;J4sfvuv@)8Yj~R*bKk6ac2b^px5*s z;S0EVw7#6!03@ahulYnKSqr8O6fq3?}t12hy0(&)WAI^$5R$gvv z@QTmbJl?Dt^=s$=+Cf{OGen!c|6sH&L~`a>NPKsP3{U~V7nSYIyfY5F10YH9u)4{jG`)}f1N+J)`LBYoYP2OG7uKJx2PLrk zT)QlSITdY?d3m;=xZj!6HJ(ntr@OTsF?H7IVL#|XSiL@jJFN!e0Ny9P&*E-|UWWDt z{>&}87BdLGssBIReYp{#Gx&$QHt7G!3L!aliV6-bP-9aYO&He@IvTzqQ6`5*8V1l7 zj;RBnl;-^y~v2}NI+ zyRv85wprnBpSkOu717VWKvYx2T@E4O^Q9TsrgiWM*-U3ePs>E7x%yfcdFZeY{44uN z69!xlWP^EuW?t>AIP)rU@l~4AuvzOoi>lqIw8L?+mEJ4S&zn>+p2A#XzT9ZwRZ4+q zm~E|jq`S;ELjn_c$IUB&{aSFr;Zg6BVl~l9PZ`Q=u$_loMn+rQiUVv{9j%5lM_L+( zo=fA*eCZ=s=NRAK;=A)*BTh!T4lp*K-qo9di~1RfrX3^>-HY&#vrca9;i&=FNC?CL z;-KwYPy<_~f}3;naG%*P5HJP2qbu}5Mj2M)?>lm*1(SDrK4_zgHO@yZF=z*J9xI18 z5{=F=b8U}5tr*<3XBnh6?shxr2>l?LFYL&wztoJ~>N zHvJ8pWBAHvq`zfaPQcFUiDGRr0L@xX3=JqX(^Tpz1GR0mdn@^%aIw`r^AmT2itv2^LM4_W^UDgrqS>F zMf918L|84oF#mQq*L! z&>!lVl~2Q=vbfpt<_`DU-D;n>J`H!XRsHknkuab)&H#9ADq_iOw+^_w?g5l^%e+u+ zh?oj&sTU`Oqw>XsVaem8eA%jSNPV2Yh-qm7b5)(vhLTg1(QU{TD=(cBxan3lB42%e z`HyiWi<_#NP!JF)aQ|c66uu(x!h~oAKL~{hz?336j+|^ulS9bVZ3@W%sRR>CpqI{t zb~>s_?2R%Nww^UJ%>-R1cZ1u?>p!xwp}^Hz?$k1sJl;@O@K&_D2`v2hCHixfA#iS* z#nDtlynj0PA^+vRXU*g9{hdD$dOnI8q{C_=vh);SE3TyMp@HNTk(>f7lBKgNavnvN*|4K_6V&(sTiVPiK40Sb>EmM&r^LVwdWJUm@F-}i0yg8QMUWddZmW)J&?gyYq#0bS3AO_c+c%_lCemS1~~WLa!>+C>l}I5AS5ra>86N z`IK^Ng`*ayum9rwCR{DhQ=hvP&rYCj1En1mIeWF1KNtOH;ACV?m!bG~@GEH0>aZH$ za*8sc1Cmj~|B~hD6;c?m(gz8e|3rJTitJ|TlxL#c&zhHqmMfh^H^txJ0cr2&e&s14 z(7R^4j5SiVS$?jqA-oD~tD7D19K#0mc2#xL;??uGEMCPKZT|u`&vY(vjH20!tZ>kj zd=U&uY)mop$M2`Qw61h~#?J|{9VWqlDeS}1`bAp;+iLDr5KDGGET2Sf5u+P!={UmE zBp{h)mlf#EkaJua@h)3a(;SPFfI3+3V< zDekSd)8r^4K~*u_lWVUy%8iX!U>4svfQb|u$1_|${+P)DM8vA>NhXSa$bnV`6>v0M z@70rW&6j<0WJI_Spa8vr<%3K3KFahU)hsPyY6}C-u2CSj)#8sd@fRuN#k$wH8Y1CK zBBz=GxoCu@Qmx6CA*=fj&8+XWC&_nw`DjT(tu&yK>bpsIw#cAiJOSA2?=lNa*L3Aa z4D|vt*eiy2QHi7Ucg2`mn{f{cEF+O(i+L%7KCA_)6)ND^@g?}7P{Ly?Ss$XoV_b4O zM!;AzR}%2Xk7aA8dT{KHZ1mPHBzylFv$}ahv8m$;XepbuvP~~_puD&$Y~L;tfaJibfB*SWC z*(ZJ7);`c5sj-&vSbyvMZdWu+_K=f3fuYF0G!8^S%6eL|-hNLr<#c?YynLL)R&!$Q zWTbP_kgx}R?Hpen8~`AfdW~VQsI91_G0G>z=gWwMTV`Y+g@DXZDHkJ`@lQ+JI<2p(#48RjPr zbnYy^9C6!&o=z}=(MF; zE5nC_*BY;~gOU3+u^wjCWz`Sg7s9ero~sds8{StZw1j;hEdQK}I()6MM(R(*VR8Ede!NZxC)Y&G=G$whxxiXV?JTemD6@ysi~^qCQG`UFaY%z zB!ab{_Otdr&XWF}sDDQe?4}Ogl-I{==aXAna|)!&IH4_sCqpLc@O< ziJGO-OiuVR=tV$_*fAP8dJ62oT4UMR${9NqI;4)J9*mLQf=9Atq>+`qd%U6O4}*z9 zX~XzNdWGVvCKAC3L)hy{uL<}C9I*UIF4=w zLLt?jz)0+mW#&Gn1D~iSbulJxaCLw+)x5;Cg0H{=vFK24)HLFgkF*(w=j$-Hpv$SH$qlmCws_|Dgk$AEG_31BM?)Q4dq|=EHV-k@ zAFppMXXxdu{bl{9Rtq-pE6@9_lBUVfUyjZk*5g?pFm2PS!7I#=P&P%2UkW(Tb5zbs zb9uD&2>42{J9+SiXv1W9=ty1)cYwa%D`sm8o;g$Wm5t4-Fd`~CNB+8*Y^dwg<85M}zjlEOeif~q zos6Yr%nrx#+DnSN7XmB^L?#6JE(rHA z;aOD@PYAQoTjZK;sQ@+c(pTdTNuY&F8^z!K<2{T20DPWO;5FOuq@OC%n6bZAexswS z9;e6{!lS{X5&ql83{N0QXqRslc!j)sfSjSw-Go2VYE=8eWycEqo$>yR_M7~RigxYDK{J+N>F--=v2I{}q^*ea`6v2^t08s< zd(IfOp_&^B?mmSB0LG;LkNfRr0!Zu0*DRUKj=(=ZF)8{YGr?k~(3)x;H8Y`XByk~S zv|+K%EHiTZLb!oBB%|jFQBmyyyQ@3an+vIhuq2QKM!&{0TN;+q5(%k+bJw-{4LhX( zT~y;SU45QO%v>{3ojLE!;@F~Cr^G9lNui|*kKPxA#c3IXWWu&SwqjqG;837G!#`co z=w6)_W40pl_@y)?Tr^QvFg$UwCUjbtLNsLB<-aCx`H1XL@!wdL@&9AhCZz&U<3brz z4C^llQb7TIQt7Lvp_su&nPHh>m}UqFS^-Kj1UT;Lql@F+Zs{F^Mv1!5`6_{&A&E)) zGlC=ENvxB4Tglp|?;-ET@Ob)0R5a)d-k8wP$-%+Ov`p*ICt)zbc}ulR4ZRktz@PM) zz?xHgw+kxJgqaT~4rd0!QOfJrkX@(>;=VICf3{j}km zONL_(giC}2Weaw_U8lJ06gPq}+G2@rmRNdX-Q3`nx*ba<@c zV%x8LY_04qeD9THm8xet1f%olCOb!PLQWoQiVeR9I;W|2IO*=bu@)gy6S@|>JF8=P zVvc}CW%UN{$vXZw%q3xXA zT2Ucb;d&DdtaXQN?(6L_2EQ-g-uCU|i~h*_sb}sdp5F(O8_5IR;Lq5&045Fr)KD-b z0OaypT*qs*G^e}aG^QysgjfrT5cVF^`Dz})ZY4EFxMTWpHo#W)L^mx<^cj5lS6a>P zuR3`}{A@?^%3|YQ#*HxBy#m=^3t^i~;e*q4*($=qevbD?t!A`bNHLRJl5A}$<`?sO z#;-2ZZ}gM(w_BOlk!$nL1A}qjtTIbl^ZNz}hbSqu#=l`8kAH;b(AtU) ze7y%u#9?v)$HqtTX?TOo?L86|PclE=RH`Cc&QC=Z`;Oa8C!~Acmp{11pDya~%qY`XEq@3rN91-8K?!hC%*VR{nvRzZFtf&dgxU1D zlBl(;ZEU-!)jRhuqk7Osu~A+F%h0r#u!?aQXuM-L=1qp%+YL6IL@C=IoOGm!)R(+K*l_8GAFvONs^N&5_*jIZ*%?}Noj$@^hb33^e}BjbST`=&KJPdl zXh9KYlWS>a(^fKt9!R^aD(!Z`c0Hdky>19Y zF6ry-H1XmU$}X~^jS|n(L|;k4eFB= zcA;nZQS%f7XGAG67&%23U?md>>O`npF|NrR6GvHd3oW|8`A7-A$`ohlcq({2glYHC z9VX@ohWj!DqbEx587lz9-PLRgJJQ{+kJg(Wu?-Ji=(Y!(F=xYr0%(hi{6|AGdI*8e z=%i4?H?OHU|{{Sa1EGYfY5W$oLhQG$Rkijy6WS+NNK(dXthd zBEe{cTPLn|S4amhH4+Wyvc7%BldUB0ZGg4_cgHM*KoS5!DxbUj1_Oi3^Ke)2PLtKs z+usBElZJ`iH^7&#VV7S7)mb%swhgl-w;J=bgOW-mT-&);qO?aWN=OW2Q^+lp2bNck zS2_0zCj$Yfou_;_+H-(71wS;iF{&MBuk_&ntYM_4PUi7hqnE@+2)7N3rrVTAQDvQ6 zTeElY;vLTS5QU8uE2`?I`AJEhB&L-!9s@w7_6x?^368g@AB0Vs?U0+V4al~h)R-Qk z3ti;CaZ_=}{#Nmq8`h4*9pK(A9_5)JR&Lmt8^#XAWBp2k2#}r{OPhkUtTT!JU6&BX zab`EB95Nu@c{k)x{(LK##t17__jlO{xr*>d^WBY?NN_((>yW8<4Q5@R{uQd=;^$uf zc(QiCJc^cV3SXg(1)CEl?e;GjkAc7_u0^J}$sm4HZ}%1!RW6D2q?vk=q2ZJj1?_yI z@hMAg8Cds)NdOKU;66$KW@#SCMxiH&fG*SLhAO(={8u#`-WC9K$?xE_zwQJO&ncU zZxKrdi3)nyT=RQaTi-P7iUvXI{$v_D2@S0`+*Ma z+Vud1INZ~aKFqyLVj-|dyjtqc-L6lQ$FZpac>cv=ycW*ODr&5r7VjEvlAZY9ZXq-M zB%3k##=}l0@{C`nNOh^w3fn0BU>x7U30UA@&fC7A)5+ctV{jEU z@xs+#R78r)DN9*H9@=MI%2p~i6#k2FVLo){7oo+ekK|)m#METfA93mBAeMe9@K=no zXzh_dw*2{Hjx4|(F>6RU9sB&qFp!+0W##^gHSpD>RmMi~Xe+#2AdVe5Prq0jOO&3N zBbF$mUZ&oLg>ghwbBj%Xku5H#w;|H=N0Jx~^_C)6Ig!98jgGZ}ZK|f+0b?y}yePA%_4BOHau;X2?92kYeFPzTm zm(C{oPyI{zq!X}tcnR?WSIMN1w}Skk54@lb*uM`r%FC(>F7r1bbBjgkqG164hr5q> zdasmtHeH|&rVL)tC^YY|E_ERnj#Z95LU1C3F8X^kIwK4QRb`y*S)(8oW6pL*H@HNb z)z5F+qbGH0$9Gd3sV#rQ_@!L5xWAKL0rPa<=DTn)62JX6*0X9IFe&qgj&K z?}?PCzcP3D_0tFvaj3&->%KmQ?2KAUC-K${ueIpP%yiHx+bifEJ&QN^29Q8Q;#il)}{(0pv_GbV**ux;%MO z&wulG^8cnX93Lb|m;%5ddd*mFMoTcEj{0Su6Z_RHi_!IE&DLdu$X=!vV{Mq|7R1qJ>Gbh){7;VHu=b53kqphD1CqF+|l0{^k-(GgfnVmxFu(@BZ zV0>e&*oIs3=I~jxsmYR~NC{F}@U|77rMB?JN?FLk&iBSmUuV~Dq0t%^s5LJSa5}q9 zCX)2d?oi`rh_&r3HRx;cgEw@9D<1$s2>?{;=A+_@SO_VHr{Mb)2}@)JZ&X&E#~=IysPiAE0z<)0sNf>1GO%|YB{$5Np5$Z5xFV8*+%SmI!{$!~mVk2i zulpRi3W_Thn}Tow;c_of(RO@>oR{cL!>*Ry8bQ*4F8R{=gXo;{%yMzQkAmSlm6Uk} zd|SaS_e;TSjh%s3Fos*+$e0;Jr3aOCxS%F6j>L_(W7~9Hh_5ath~iw9uSrmDCoiKFuNKvZo3k`{s2>Fky5yX|JPXiWy;9D?i%Bk(a1-}9wS|x_Xi*kj2};Jr)l*{9LVp^ zw9*TCGW*cl-_=jM_F|TwuS50`2Rphte_<49c@3p+ur?nZh(SQ52;pEW{WHfFgPL|` zQ%={{f@xk<(Q!AKOGOwNio}1<7MS9vCX-~P%#XpD0T`M*6HzfbL@C$ArY2yzju ze+y4&22eN95H-#{m4vW8)p{+;) zwDPiZJ9y;Uw0o`#n9l|1FP*3blpD6j?O;#Rtq*0p$Cz{6lSD*#_;C`d16W_v;9|v; zv!ob@TY7Vs}))4t(V+I)8aZomfN1K!*q$ zA_Q7IH1%zQ2(z(!_6k|glw9sz#f-iX|*rR*)| ztG=r|A7u#;HHcN{^jw^FwpCY+4dKMf5KWt-D}2Sk*tceJQ&We~7Xy-m1tEdt5wUv; zKr#bP-_K0{DzpLd+gnNWVg#)|T5$;Qcl8EC8lTF1hQLUztSiDe4E;I$`%+NKFR=n39PLSJHv&u(EPdM)f>ha@TGaM~RAbw~9$yz<`R zJv3Sv$cle;0?%<`vg_T?hyQS-ORfCn#Yy9FI^z-~^|X+BY^21v-9us5qQ|nOXOooC zZ*Bq7)KeGPW5`AeIRG4gg?0~9_*LqvMFjA0x&eQ($8YRYRHI95 zHkLBeibPQGHI1M-p5l0kRGdEf>jve5xy6AR1#dM~kV6vBu0PrGzE3toeE#xm zu=MtlH7E=8qeLv8%~0mD6tU*<4X)q%oU(#L5?Pal=E+dgUFF8bk{gK3snCPM*+D=T zc9lWsAqT}YwmfAL@V3Ns@_>Gez3N|PtUA5vxenBM;0`hW@Zjjf{S7NTRH*WtSjS|h z1ROZowrWoWno2Q6x4eut$S5R``*l~9pG6?02qS%|MyBH^-#>g4Fd>V)^g1FdY_*Sn zm4KdR7U=;D8(s&Ub(q2U8Rd(%I z?0v1zIJP}U=?1>Q8k<`@LFk;y5}78(A4ZXqmGQ0_7NrX2p2wEHpPecM{4k-Pj!?PN zW1dHZIWP3!l3bG{<7_yS)&WY}*!Dx`d&jWV)Vod=ASHdGdj|=?ZbNB<3h;UmET!mK zDxvE8|F@nFiMd4!`}aJlrxbA$r|1X>0;lAMzG4YmI4rHcRj$;Ypu?c@B-Cw^$UE8X63ZAf0mt&Q zy~4n552vLQdP{GDqSLL`1M#IkU7~Y)?y!l4!Vrr&W#>%A)Rv5o~2ZUj=LX*GR zQFtk9n>Gu+JSf_I4&wd(rNtw7WbiXfUelXJ_1AiDO;L0i1JN=x!RH}yt!7iSKC+c2 zgRZ1e;VMOD)*xNk? zkT)HcC8r?ulef=RMd$JtLw-t#aI``~m)nW@9Jpb=5p@#%h;86qg`Dpj320#`AKgGq%ChgU;Ny-eAy zAz&1Y5s_mDlvCzI+`#W*eQ?X?=+u(WBkE=HR2fhgpE+bfu>r;B5zVqUJ^;uwaKoYT z_puRMn6m@-Gajg>pak==g<*D6>EYSwK>j`IPc-F5H=}d z)uw2%6{o2K&=H5DE*_WBv9hvZ6@mgT9Mvd6Z5kf%Vw|&VpWiR^!fnhW0=Z$ju?ySm z{(RtJ(Sh}QD2r>g{3sMBwm-H+7Zf&qs%DPkS9E=sRLY_DWUKPx>m|XY6|y) zXO|R;c%*qmEV_lR-+UwB=Js=*A6J|xiVRFMvD|FcMSaOsDOGyxAlJxhUeU5W(zv*` zCNu<8Bz7Yb$>KI-IB*8tHpe4AFH$NDri3eQa!){xc%WN%7Obt-y6bJ77c45YxpG27k`w;2g1afr1&yaeL6VJ zO_;=HA|<`iD-96p&MF^id(fLTR~;oSKDh;oo2fYN&PdlW-ssIf zQ^GyOG(K6@pxsyo*j!Ol{kjf_65Az7YT2N!w^$!wc<%i*h!3@ewm;B_H^PqR#UJGMguO%A$!i8a6S6s67|z}yS3l})Sb;CiiPgTL-uUd%h>%In z&2C^0$}Q0Ve;ncHw+Bqgyz2HHVVeI-Aj0?$YTeOu`($eiGNaie2RbI|%-yPWmSx!3 zHKVRDlnmZcw}fYWEfOUHJ-L5b)FI3h^GrVUz}OvChn9Wwsg1|hcs&r&-Vp=R;TRi@ zz0tqpAh}dpEMWyjhx7g+O|WjH_9xwZi0G2JHrJ~aT+xuZ*%PikJyYHusndOlWIdf3 zt4OLYVJ*lT$bUDzk(i&%9|cG7O(vFIFamrUMU+h%INu`!QQeY@;~l++7#CsCc3+VzA)c6_`+jHlaud(lFpWkhi zrBR54UrUy=5k^A;E3f@-R%+y*=1hEEAW&{K_oilM{@hiVQmo|u;NwGF=K6)KnUAwE zu70AIdCbo4Jdx-*e5lx^IwLz{l-9Lp3uK5Z*)EhF)Wj*O7v6t0eZ2e~+3Nk?9;A;y z^gU<7wLoJMBM)6Kk2;oRT;O`-)z_tk7sWy!x6?X3`2s+*)M=~m{sYup_ zdR;Nwc2d*Qo!h47bR~@=Z7HUFuT1@LD{IpQL>U}k zy(F@_@Gl(#3rYA1+HqbvgtrL?u0K1*5(WG2i$y-C|!Qv;*4o4lPnl447`49&@0`ghufkx$E@* z-|{(o9hb@fXEoCN&ua7$Qv|9T{mV9E2~&oT{2)UdxoLPRv(3>Yr? z&Ii3~N#HNkN$ucpqlV*fJ5ddN{WO{&(YQ!AiEK;dfL|${q|bCh<3PBNt<6*UJdK$t z^L|!N6Kwus+upyga(SKQZq+v^E!Jc=a-ZlCslyE991KzT#K{w#I0td9Z~8+Cwx@xa z;b?U2i@^wIWs6kvvPwk5($d)>!CC^UQPe52#66rGQ{z3Vo!r%&bOgJcew<2toEk)_ z&^Rwgs<8SrZns^{D!?KyHp)f}Wm%h2NYokwi(vWCVn2_I_8zO4zML9NI xt3G?g z4&W%tBqXPbR`Dfgu>2G0TRV&4bOw6_Oz;EyI?i0{oODJ_4RFbbY2RF|&r|=*#sMY0 z^C#;!#u8O8tq#gKuO^NKL!6Empu_>FK3#2qJJ>Gg2Xel_lYOijF0X4d*)|59)BO|T zbURajp;K0G60wrdr5zug$aA=!F{BTlUpz*+?~5|qnzM^2-)J~`dY|bTblUUB2E0zZ zTVU9xOlwKgGs`C~SzmLe70MsFd5R%^#gG5Fe3Ew+Y@7c|KK8HV)<8aT1VdaD@53hs zK3VM$cX*5nGAbswuZ9B6S5JK1%{5s~{ABm!0TL{o--m-y_e#*5;Gks<%U4#kR=7EL zfdL<|duXWGB1Xo2BtemLawd&4`z5*w2gbEEKX_nZG}cRNIdHbz1`hQ=nDsa0xI&=n84=S@gGA2DHTxQF0jjouQnFsD*`p#;*Xg)EAP?cklFQb>(sA} zqNk`Su_t$9u&Lr6nlrh_xaFqd&PVrLKB?HbeLk9Nmy1i)h$6BN$+5&R?!ns!q3|_` zIU3m-`iV13Iwu;dUqV*uj0CvF&z5e_ryG5NqxHN$QUepAY&@Sf0-)FUqLKGPE?Tuybs0 zflp2+u!wi|uhII!bB}KtElo6FEaxecon!{PqX04^gX-k2woe$JhG1$>RKotzO>vVX zf~U5Pf+n937U@14ClFNVgE3AL>-48h;3<)wwUITF_%(1WH zt`@dMMY1U6RpwZhRTz1f@oPD?J~KglvhXauZl_U5!YyO@N!e(v>YEx#ue9$_$}Klc zs*8wd-HSapLJ!kDq;u0txcz@oOi2^~qFdeV`n?vl9v&LL=}oqoP8OqVAI@`b-wuJV z#+?@iA-7*ULLx$N1cjJ#h|QcqZoFJLn_I{uu?x*pMmvmx_kgMF0znV&_ztnBr@!8p zUC?2~#v)1@;PrS~$vt15qCoU8teD&L%Pq%N$EZFxAGBC8hc`FVXvTO(Jo_M1oy+eA z^_7k=J!_a^M?dmq@x)*>Kzt@6Y;3xou%6JGW&y8B> z%%tcqpf79fPUvikJUbYLNldGFu*~+sGnDd&gPW10*$Bj5mAH|82V>xVA~7jHbf_8) zkrU#%C=m-jZC@4f5pIxXQAfE2o*puTv=@LPB{+ngnBZA~wL)Sn@ezgnuokGZkd2Xp+j2&bAixl>UdhtWT=nGFR8N_Zz(q8aI!vtcZ;N^| z_PMlgO?9N@79w`#!HdJ_Bwwt`%JX-w>Wr?i5{#LOhtl6{jm^1C9Oaw(Ts4^QQJGmt z?>sWdDdptyT>&Adp*tjGx)@ko6sv(%+W1Jaia(KUfv12MZ&K9|@NE-I_&1qmt`**Am7|)DZk7<}0lEb(bp|OCIpv*7ETM(?Lz-@t>PU0~)D1BEIs_YYB&Z7Oh6Mb&2M1vC4_z5qw(S9VpM z(nw04WrEHJBmi=6!AM5Kz2u>-^?$<^=|x*Z=SbD^Quu;#a7yW-gd-TEj7IIvbGYQY zQ8Vby5K2EmJ!6U^gGiQ|bZ3v_9_J&t&?p*X@#E{>6R_(qR}F=yq_Ior>zBGeUfW8H~Sw?Z6`>8E+J9iP_v1=!S{< z7cL7;5UE+f;AR)G+fPuRb7yyAU!d8}<3ABR2{@VN{c~_q!3x1*kqkaMK6X6r?3z1<@E1h5xo+)q%j zQeUw}SQ(M@cv>Ykax~-it9ui1yu#%7$Qpo;opj*sy1)IXM>x}9{$ZmYTBW#%;qVqi zhZibvl2%4P>LkNfDwL&iLfuZ3WSr5XiNO嵍U=YZ;=-zZAgMtk6W|B6MQ%mLGOi!VUaes}cKdr_muGq32|u=mv-jYNgoNsOp@ zB;K72fu=13R*PL^aT$!#sVQGJ?f~HB!%Ib9tE@^1WK1dYJ7R5DOp{+4-+E-}$8FT@ zyL1$dsV3Yi*RoD%Pd%6nB|H~}^YKF^EW{ZID$iO!8{-Fgd>S0m`RThtkJ*neX0c2s zx0$b4PKdu^^5G{7j1+45W96FJBE!-2{^S&n0M8pY4s8ecvB0N^Bls;;h>z)k{)uRawEo87!8`(;s^s+}iCoVW8wG*9`O#GKq~&;Izsux{YH0QP!kVr1Q!N&Nz2#>9mxer6DN6DN zGDzbyF)74=YyIaGP6tt1vI`p*!QDtLNG-MsPdc6Ecg7u)wU1|@&WKa21K;=A+r8_5 z&npWTw=nKJZ|Gaz$y7WU5VP}(G$XJJs>QyrHCo$Gq|^xaitx0K#@)d7=JWe6gRp)m zvty%aMlK3*m#oUd3*z|xQF#iTfiuLjJ7Mzzv%|NHHnaw^^{jFkm<$n$rSA=Rvr2(O zs>LK+>Tubw@yD|{m<4WvGSt@qwCkOmVH}d4b!!o^ITEh-K@1ASIGXnZ+ztGnSm!K~ zl5KyY-(mlgA4rd?Ww?I~5&M6Jq$#ojbrF0GwXpxXaHhNn5X*Cxg%@EJ6a{-GX9crl z(l|PGg)@aIFLJc#npiv2G~|7A@x4*PNsLBfh(@A-ucULvhH*+$rc087sqQEYi7qbQ z?_;fOCA-`DW4$QHu^P0&y1V5w+k3L*G533}^W^z1;~I$elK~dA`Et3w=`T+2n5FDrp(1XtkIn(K3nBNyxff9Qa@)? z%r8vJ9TxBDw=#)cqqC^rUB)D;J8m5X2AnN;L>^j?vhlYgb4qNPISaPn-WixPS}-qg zk=FN1!&tym)rREl6UH&w=$fwsvwTNam-I+To1OylH}9TSBePl`TOdh?LX6%TN#@S2 zX@VwxvgEsCFLNwg*C~QFsdlxb#4>UnDZxEXR*u(vMxX%?-yq`oi>n}brS&$L|; zLnoEiNEBQ-8ta?fIn7&P+!)fK(MIh6w2C?Et^yMTEbU%v6d5e}G@EMsDUisl1rZrV z`Q%*w#seI%;fZTQGDdd5rz5f4sOICMFL3}7jUOru7xiBu?BWaMY8|X~`DR|Gcp@?B zBa=iqwyq8>#B!NeN8M1?FZB_0_B_gs6BPV(%(Wm8wU_iUcB^fWgd%l(5%tT17ckZ4 z<;zc`A`J*^2nBL9p14lH0qBg;+tH&Id!2{`0_iT;&nzh7BU_!oTMHvJv0SpgRnC?V zQdOPCB=Dm})O%9k#{Jk)V{#R~GLVO)_heQ}3C3TG| zS_XXN+kyg{>!a9?3!%HrH+lI&-_P{;AN`Y55{=2)^J8oo6lsO$X(g&o*moCWJnC{= zGwBDgNsqo6{yoOPspolVo|~W8=ErGe_}j#Y`&v8p+Y&#^V4N|-)JZsJ*b*`corF_L zLZR7D?+{dekFL84kSLFR(j5QZLCd)*rqDf08O}UAFCmoH5Mte`C7W_RSA#J?#0n;A z$mFGu;yKrhTgrB@`?l@fA?>Gmh)*#lqP?5w6n6@xXUs7UMEMDc-SS(aJ}2QwYW@$g za}Awh4fzktI8`i;;>TP)YPah03B3C32sJ|`Y@Jgd2Hp-3xi?eG9CTr&XhTugGGB?e z|B0^?#M-`>=6X4ipu{7xXB__)^AVatw})zt>oi0$ttymFMqiz(H3?l2Qy8&<27x z+M!q*J`4KJHZ60GC7t>k;*^qayQ&-Kk-x>CCfq(5rXH>L+GlmSUprYNbZpx;I(G8+^M3zPjj_hsXZxUP zuA0|W{mWH3HIR@vKKIu^kvwhQ9r2(5h@gKk+0gVDf<>%{;?D=He$MTQtL?y?QGa_`0J}F>T%-%%h~vZ*z=b|*<5}j2jv=B~`7-~&DU@q-mHO3Msxi|a z+@LHR>!iLVD|3rYZLaH!Girz9?=(0x-2lqN9bc9#nmQXL{3-4SlfJJ&I zU+IB_YDoFFUBTy3td2G9DAo*jt&QT|=jf{x(92#KoJMj~vt$ts_NgUlD35D&G&|mO zv+gwY(SvKpBAa+OledW_ro(k<^e6Uu=0Z~iNRNLz6{iggne3B9s9(But{CK(Sw%~D z3IPL+zurXdn&S=CL)#Lqru@_rz(sfX7P?qeR_=Q~Yw3C^K2jj6RyfyOblBh=+OajV zZ(eSGZwME!rs0oO5@3>+U+ptUk;QVIYda?(lLo6UFtdXHIggVh@CW2+{u)f51`9fE zC&+d1DdJMQTkEW5MP&@$QO8+PA}3u~L6XoLXUX)> zn6A0BdsvtlV3G7^px-P3a1@JwsJZD1bUh7OZv>K+m?L-C&gl%KCxe(@7B2H5@|hJy zFPU8IKq1|JJOKSj84^muaA~S1W_!zEduI`3i4s)GKwzQ|X*uEVxegulFqBK~R1>t%rTGqgY^?8O&aSSEP4{KvEK>r@%@ z2zib>Y$CqD{A1dVRh4 za=PPtt}W_yhvY{-(?5}}_*%mr_Cr_zt#MX;qA_s{T-n$Ppk;V_ggTv2T5jQsJbV2j z{O-b7?Bc{$Tjq0weZNHS+>P!rb|=3@Kl*iq$;2=H1z#Z+;Q z`oXy<$+1NN@W2t08b#hS)>ncR=^)1w^$6wbf<0^eYtLaWILs<#6#HmZwgcZqGA@vp z@?t@R;{h2YU$CnCX3Bj z%uHo)vApBwbC4z=a4U+!$&ni8GbO z!y8+xhcJadT#9=F#PmKIYcGB>v-xbxH*Ifj0(Q{K zoBdh1rAn_Fez#hA+3!&(fH^NOYk)ieDEZ{{W+_r6v<$snvUNtllOxmrhy@5wELelC zl{t=v|PQa*DnQM3ex>m#f-U;N3^u`Zpq!}*{b?pB%!OEyZj3j$a z9RQ@S&T_|3Z!V=ecaHpSB-SJjiuHwG5y!{=YnHRs) z&BL=xOhW(UaCKUaU)yera$PGurH!O`TmWmbqLhFMNeeSMvmx2Xp@V(dDcN^a^QC_8 ziE!Ng=78xN#|^@Bb`pujb1^~&{VH`Bh{Mm&XZJjpw+EOQozXdzN zsBkCGl|g_4={mX$jyO%~)xZT4Y08L}rh!IRrF)KmHC~l(j`&n@Th_Yj&>CJ8ExKog9a}K*KJf~KbL@1txe4Trm0tZu%TGmy|jWOI4xGKnw1 zuM}GiGLZD+8vD;sXo*ioX>>LF$l!UiGiD*zgjO;{^+NY-T(pQ#b{!2bS>HKvqhIulB|1Y7o) z2$z*~Aze{6z;a=1>3SUlG&BYLG! znOp^X#x^XDjA-N$#fn%Dy2^E+9Y+maYdTQH3Oi}Boe7rE1W;KH6U%lP zTJoW%D&n70S%J~o$QhC07HGab<5-F-t~+B2D)Kk4(wG>AIVKd+hSn-Yp4O5r$>+0I z+NNK5q?y-QlMx7+wqIZ3a?*+(yk8EQL*@J;kYntaUBz2FX})89ilS(`A~{3ZN=2x` ztC2=;vKU4J43fdR-gOm998eoeZjvEq;>vQhz+hcE&Aq&cF-gHQNKKmIq!dM@sIW;_ zXty7{Uzp#r0#gWS;{a8LVbu(}qq3RAjI5QBp0IMJwnuCBI5bE?qGDCCk3Y*R{#W9gK`(lD3KXAK;&vC@+57Qeg zna|k>Gcz^#iNg{U1i)o0Jq8;)Cf2A#stEO$Qzu6b6NN(4y{RuoKI1KN4}a5Q+Bv)L=5y$>!Xnnn1)YL!WEh>nbNldr%U6mhbXBFe-t_AGRDX=eRvZyyeRz6AILCQLkq26( zf53r@h4+-3D#cwkEk_R3$$=7h&bPU93SWMCRY?zO@_+E>+~hA7#)FSKh|7$pA^o{2Z7u?`~|Fvwzm!TOI1w8c88_Oju82$ zI&*K!e91=c*3FB^eruo=_o7~%JS0d5kpMAhoW`9!f_tMezkfm2?s}GM7mfZ+fU;1` zSY&ul+$+uOjb4bKaW8(j(NkJhX4V0u%)p+FCU{MoCNzIAHi2~)mSg-;N*!!4D5`@^ z`Qlid_yOP@zJ(_5u!nj z*BGkbzpI$miIu|^W)J3M8e1U)lrX=xXq@^4D*-bmuZYmW2I!eG;|hMjEW0J2;$^#$ z3t9wBN!YFp=hxzOQT~*j4JwZSS(eerh4`4^qQdWQf?-JDzmPe(m^Be9thZ8VngFRg zN+M9HA5j1X*>~V)0#l6~W)Brk#_74kFAQ(6;^9DVy+_1O1Iv><kaT0{P+rMNNC5l`a_P4EhWQdkFf(_adEK^GWA!cQmhrKG;bieE2>s`^Hl!#sH)1 z_HG_sOZ9ti-~cD%KjjM%q4~6UX~4U_B*sTIA@jzu!eD$_lio3k{^lY5PPF=2Au0Q0U(V8dT+st&jd#SHD^9SrCzL?Ma&QhT5{7_moH!* zggcE@z0_;Io(et>wSzhy&Fo$6*nk>*j=Pinkq&j%nU+SUlbV&^nJ>}$?I&g>KvaIX z4wwzdVCxMQ)>{y`LDdPoCkHtgMCNH$Zx0eNtXnh9|HxCJS%!~$d|1Cc8CBGvhojqD zi5k$2&?E{EFRMR5Y2PIvzXF*o81N6n7&jQ?mR?K z2k|gKAD~5vbbpa7=<$&@sHYMo^s!j+8;Ld+;vdL+_DHhGNMD%Px;M^5pKlFw?8$Qa zTfi;1P!Yy(RVodbsuT5l66-!v@J^cHc*vR$6g4Sqxf;`+J~(1pmHfo;ulhB4{YyD#!~;NtZ|FSTtcjlz zwQGb9Jf#+09n2*y7seCASAn(o32pBZDz!U)ivcsj$wqCm(#Fx+e53>BpVuvD6s_30 zQd-68FTh*>Z|!~)YE5YGh8^?bE+ zWl+|DH|IS7pX)vp+d7KcZSvTjeF@e*EDSP=YA? z#97>m0JWcoNnx@pX26ym&Q+%&yI+#NNtp^wMei+m0QXGjbz#x-fg<)KZ(1?>ik6GhGq$QJ`7vo4vk$WMe{IA!af>T zvKxwHB4x{5_f&0#NJs{bdw3e_VmD|OoL;pm^%cJ6Hv-BV!Qv?Wefb{Sj6G`(H`yqV zk{0>Nl9Pw-7nTY~DVLC97SuC6+;?OUvGHjG839nisRgtk15O(TmVn)Eg~{1aQ^}9z zs7&h9Ieh7H&k-n-#Chqb7cBkCKR*J09|{HY zXt$&JS^hA7Y6QHKWOF86po@xeXF@4lfu7%fBIe{-z!Bnp6Z%)*%UGHo<%pPU$>P?b zta{z;Bmk}g7LP-*u1$G-62e^lwF(NoOg%WJ{+qBCu};VY(A!Z~~u!%-9gIqui`!G~9tG#B+w3*rrjA zaj(PTv8Udb`;cvvoPwW?Gsdkzy*1AjAq z=tackkd9&BRk{yy*V2CMNY9i{B6kHaXpB*?Q7you!N+O`^|s3KofJ42IBV$tI)^A% z(DIuVL}^#I4GX7&4h!_j{-$pVXcW~jr&XgeprRsqrz?u-NorJg+G8%!)~tmHEFM;n z`X>{jW{|jBXvq2IDpxH*KOQ@pX;!oE(vA+Un-=4KbuPcd`dCd!8xb3xs4N|5oUK1d z$!OK+l0Vjq4+>HO;w;&iw<*9nTfqWA522zdGMKrCb1ZYjfSXrofpaEO;KevFh7~It zk)@bZdCdvLK(q~vp=1)GLzyyG!MlT+a>!HT+ zw_(FQJ*8m7jM3x_W#ZelRCZ?|$@sEv{Owq;vG1{ACfGpZR|2|KenuGuzSt@lK>(m~ zlMUy{K*k*=K24aNIWPy$y%W=Z#_ zNf{10oX3~48PEnp(Kz@>orVLXA%IrqUFzl+3YLA;qN8i1Hi5J~J{r%kx{YCn&N0xj z73TYV!tf-Rc|K>PO>a zHe>Q5e|(_rIsq96to$~(h3kUfe(^U@Z?kh<&W}6(omF}Kza$A^!14U+dh%|(!uiM1 z9KCZ8=PfeiAH3>By7P>@;Q+;;*0B)s52HPmA4bF!a=68eKpOeE|KyS1PmCvLv}NP4 ztmqS7162ew^C5VA_>Pb8+hJd~V_(`00&XU;{`KNO>Oha!u+0NT0wlY+WAh4N3r<8| zvLtU{iGE;9RooK&?);K{-BISiud66)>k`L7kDH1DU$YiMm&aASFTE?h-*a5=t5)}DU@dFyuWXF` z3=nv+Y}z3wYA)4VE_cZ2;ZwzlJw}6zp+}Rrb2`%G!NguJ{L8TzGwkHkOo4Z4hT?@6Zd_ zHnv&i<{thI;4!YRay&L`L>{Qwp=ZX6w-AnJY1;#KY!Kgu6P9ln3Mk!gc?R8#jgGCX zUj~3LK@mJKS44ozj`nFjY6d8)az#TEaYD9(sZK z!LWw{SCyIv!bI%i=X1nZ(TfKsC;V^58k{!V{o}iY3o?araW779{Q12@r&ok}k@vnc zS$y$QvG!K+ZUCoQ5N{`nv?LDk4&YJ}ZUT9aFAsLV5nFQh+o0cT|*r(4Sq$il67n>K4@tq$giRh*r|+(RLSpej?m1Yfv8 z%={ctt9eP(lX-)f~zN^Oe(Kk7ks|}6+@qLwi+aQa$MKCep zSxP-5+yaQveh%caLdRK{O5efXHxUQuxU3BdAQy}ZFz$UF;I%pR^Y`~<%=F25zIsp& z+Ti0U_FNn`Q}%HWI-PDeyT#|hj=#REz;nL3#Qsp=pBogkj#}}PZ1d;;`YxSErG!LV zQ`x;y+k9^ap{|5~#(%$h<`;j=DZAA;MZ?^J)CMS)0*T<&{WVb~Y!(8 zQx2cV6dFdgzSbyRJxbOpSb1P=IZ^ruld5Z|vArgRY+K1LdloWtKQlx^a&eycRR17l zWi+j_B2>gUPNw}xDFKZnq^$c(RpmUPeU%y($o?|o9@mI4sbL~CJkaPqqA}Q;z3|CZ zzz*o>-d2aNr=NsKz7{q)^gL~K0Y@KXCn!ZuP%)_6tf9oFhQmz?&D1OL6`A&Y|JE&Nx5_;PzxJjJdwl4D>S<)FwLIYo8 z4^l@A;1er}p`j;JKhGr2%>IE`Cw_;jSPA>=(l6Om>(nUu2-h!Uqfukj^gBlC5Ca6U z28&D4Lc{(7?uD8q-p~a(8wZ&~+Nf-du&iHmAoDBqyHo(0&IW?I*8%F4apDf1nc?b| zTPY1;lg8xLu+L63H7Gp}{L{DiNxVUuT!5G$AImf+sxjpgF>%Ky>Nkk@cbcZGzz>=_ z_>=OSao!c9lM0@+!#5)_^GmE^K?*Q<5F3<4v=E;)^hZokbxy12#4?ffhx)ZQ##UGX z#i~{;gK^B^j&ix7gmgU!ec)j>E+;o>Y%#x`WPUzUY`s8 z2lpAr?-K=jVpWeUjf_GbLzb>NJw6MWJk)~TE=^^~`|38>@?%^c!ZcQhv8UgYkJBGh z)*lf;=ukQjWcb5y0}?4tVIIvpv$RouaZS$tnB_=`p!9r#t5UI7^dCykvUFOa$u?@- zMn}5I&c-sfCgEhmeSuepV;ay#ZWVAHbJVmguCdlDm(q%9Rj@4{bwxwps!%|CZLe)y zS4iUnPv25kYWCcsYFAf=^Wk>XMp&rr6MS(+Y1Q#%(mb#uL3@ojNAnS9+43dUIq}V? zeQ2nCsVRDi=dXRMDGw|GYv*{CUxg37amFd0h1cU6bLi(p#o&Qjm7b z1-8e!TO^8TKqekIKVK(C8i8uh-+x-hYK?w8WcJuDrN;;?t&v!k@7HF0U$ zod`Dha=)68WzVRHd(@7I-c}Wu!<5$sHIncgIxMTv>T!p+TD*)r^=XO#!({$a_e59X zSw1bc<+(0?Z|~fcx1fr;yv$r>t%DuC-hOgmEkFa&vuDR|;^2YSF)GLc+}NDU=-dTw zH&WWc!>ygCKC6y2p+uBa!6j2q?U-G+YNM!E)*Tn-bX}!eIk6!RMFKk;Og&{}<(hQX}B zZfnU;+^x_SZro^`o*)BJ}0YaOCuuulw%sl3U%4{o1`&@PG6 zh>96Ui>jw#!OLT12+LpJFM_%zq-L^^;fXT3rf7_BR%5G;MDWK^m zn{hFYa-u9MwqaI{mh_9YK1hm#2o_pBcw<~SLZ}yn4_G&Ll{wl8!!S<$YATFr@rhc`7(Ot2X9-Wx69S$~hqlC?M&(c4&;HJC9r+irx*}(-Bx|FA7 z63cKxV`Cin_5vZ-kS;QJF1b-$qMN`0YR}D+R0>rW`ObACJM?&yawVE$f12c$zMBm7 z8LqnpOM2Q{gxk(417e4upP!n=A7`To@pl!K{gEt}`MM<3~S^qpoa8nM2y^l>1pUKE30_LO3) zmp>~|KKly9{Y*}}3!JdRl-0gu8JGM8YpK(-VP+}gHGL_cph7E85lU`+yr1$MI}+|- zQTUr0;2UZ*X3=C{>_TxA@_GSepCmp*E55C7SCl=8O(1Efk+@K zkATon9Sa@}r_E_pRl>T`U9=uuj_=(csJ%V|B-v6X4-bzrt0?s;AkY*oyN+?0s4Ycf=XjtFp&Lt)=e zB7oTw^3^Hu-CUX8ccbX7n&TAL959)veU=20xZ|U?8jU?xqqzbI{2qJ-W`ye);^7np zK-`f!ts8x&8=F9L45s8w=sID=p*vJ#s2tqo#cZx^xaS_~uq;KKW$yvZ5G&G_J&cMJG*Z@V>UQ!dMW4N44G>qWXg9gWp^Av>u>SqQhC7 zLFr4&dT9={X1{~%43eK{uYgMVOnT%4Y|=KD5q;C$yn%lUFK@}ZM5@F_^f}qaC(Cp# z?V7;z9izjuB!7DF5*iYIN;I6DPNr``b>14l^xEl-avP5mD175Gsvm%Vh86O)4Q>za z3}OzapDABRiTz+QLOP<0Ta6J=qI)S9S<<#bI8z+lhn>fu|nY zrTYJgsN*Y_SA_+&4mhynOR7u(Y_aYfCa$+X!ayt=t5Jr@6RfU}v08cMV)c>br(T@< zYME&t=~0FR%S|zmp?~I zvuBERxgCZg>YJs4i_{ngQSmTuz)P$UJ`F zV@NeIt}XF~KJilH48@uoETH6Y^fCsmmTNxK2C+prZIED{IXwgbE45<B!|FEDh-&nES)7<=aR+@KVZ?k*;rZ6Ryrsaw3E0ZHsQu?ffqDx3<- ziGn^Kr19{52R2%5+QG~&Et?yOd}=(JNn%`@S)*I}a-0!1wc3BpLJ&x^`6!fUJk#VeL1u+X66s9op zj7lwCfhpcuJThMFO+2bZ5vgZ94#GaqG9u#sJn`qRusqQzJOacFD&v8mSF&2YYBQV+{jut>{|JJ>0()ARO|sOW>S;KPDDBO# z+Pr0XH|0g0(x2K!9JZ#a?iP}=lO^>>SNIB4XKVgNiZYCA330r6YWr|3Vuoc6fi00d zJ{;z=aO9R`R}pLM?Ke~uJRy~U7>%)ca5XaDIk#We!K2p5!mA?1pHUeJ~~FgVZMuRUvMy~UYmMPSs4`np87Fb z>gMnL+-X?q5;*}{0J(cOC}9dYvyDFZ7vE7baC9tlZzSfi&Nr}FRI9LkO?T+c7@GtU4;gLRLw~9vS_AIKCGJlh#v$@0vYe3M+>Pv9xO>oUha1ZW3M_@U< zlJPsE{W=B55|n#+9x7aIMU<=@dV4U#`T<(u_sr_AYn^&iz*?}s|hk^-AbMokn zF-IiZ^d^2M>J7mp@%#@EcVMd~YD&cNaGE7R?Dga62Rb*D-G4YZsC7BhllTXoyrAnE z!mjNG!YZ4Z-X(=XKRa}&lUQy6#lig_0RjP>qZL=jql>TVoxo@fU~6m7 zmd;j__vWr)gJCP-jk~3cQV~2>PG;O9F>mvX{tjML%MUfBsj{rga z{|OKdF;mRU-%7?V6;B5qJB$qKBl-PHEmu+olVtY_b+y!ElUU|`b$5fT=6w-|{6)%r ze|L=v=u>WRxQT7dhD5Jl?N^U^8SN*R{?i@nHSWGPPyr^;f*F2%)W%H=5b#ZGT)W)HhPdC3OJb&odn_|6CG$rvh^+iJ0oz z)0itN^SDbdSPLV6o6*wPnucWm?(-cWXn@ck;-+|o3l1DBy*V>;|7I7~x{?L&Q&0WL z<40c$z)E9_t2S|6KDmeGXv<7`rf*~n=h-cgf8ibY-9JCnh5lyws)1l~fo0Ij)`5p;Y;nV#NHoe)Djuy8 zwfXv|piLNFNL$*C7-oI|=YGBs3A{YCPd%7QvgOkad#hqx&t|pGfYH)*2DTGn8zXIo zbxNLps-)N^%vOB5wH0x#djbJ|K_9lXc4yov+6{t;v(%gZT4Gc&Y4!~UW<>(=k zSEYbn=)zcG6=%hr6mfd$Q#8ERona{k)|xuMuYrx9C${x&xURs)OCY|ZT-zzc9;p~> z1)*0j+`FPA)w^6_?wUjDww(Icam+r5j9I-gqqg~b@R!^yw2tba$4K@;fU7Mi7 z_!=UFAEkH`OV4hZecT#*29w<)hs)5HC$AWK<85Da|FDJEzzv7(0aOdoO)zT zl#BAY16*eAgiG;$UmmPK%>E|AZmXMIu!xF0ad;sqq}xcr^+s4;1ZT@W`T`FDLKyBO z)pDF-{}$dQ`~2O=9)SI8nn@b+93gOIlD6e?_5_Aq^h{fvI4Mb_Xh7aQQB+j-52Bkxj z1D!*x&0sd|ZCJMHM)M=m7pF#w(>v8s@ll8)N~5MjI6-lqA%Ivwt5-cl${SXkd${GO zEa(x5w8B2Lh!9+AB_l7u&hEkdE4b+tMN&cHadv(*@UiLfVGAgKvwl?g(Wx^e=o2;F zn9j`UPOZi-?ZzEFAl@L~z*LW)nu@&-_H^AV(j!;=u3@^dRz~Mk^(pO)W$FWKHIoPS ztW5Y3jB=Cx9bm>z#LbAy{#Bh+1^}lf+`GVTopSxN(oq|BrlBRwA{Pn%4I1r3N}a{A zjF&+xyo3y|C{<%M-fD_^R(EnVZi#x4?yjp5=EhgGp$*XxC(2^KW`a+@&dzwDNh6*j^uI-yA1BtLyDXWLAv8hi#gEd$06b;i!T&fTdz0`yJ4(<+bD>Zu;PpxCl;#t*loL!-d}OKy#+Bhw3qkay5G#&!u^&SF&{x6YwU zR;_QwMQ7+ox7e=nsz0uN#-(7$H?SivKw>@Q&rd|vhQNZy>uFIptQaj-!5B3Oej($6 zm-yKWZPi(Bq~JSdH`x0W$Ut%yT89e!T%WVJ1eh1sJLuzWAXwnNfKjj)p&LxkmCf6QIVxrSl+%3Duk94Q~gORhu~<0LTxs zPrBgbp0P@;O)m{bE4!@abm%_CJuhU#7tpBQSiJY{-{s&Vts2D#zcs_n&p>wyBQ@b% zS?FF)=P~r=xn;7fnaM6h{S@ht;hSJ?x@&yI{-DSF>d>UHypGS6ur2r6W;kk+yx_Rq zY{N`W5wRBgW>evwqE=5P^tm~Y1oVS0TV#De9->n?VI2PRP`C3^sYlXQwhf~PXdIHD zG1W$BopyvR@!0%K;gGRKoaAegu8>gf4KG(|i8q%0Uigi6jaHID(P^9U7EJ8=PtC*M zgmakn24dHeEOOX%IDgAkl7x$ZG7LPXG8|}P;=)I2a2D?rEhph7o>;gR0=SZ%Z9jLC z<2e+0`J8Hzi`pXL1{wFPfh`Jtw`T~nA|8*@5h+D}$FI?~E(w+~sG?^d67Ws$+a3F> z=+bv;LG0W{6*Fs3979FebW!jsg{GP{1347(Bq>&SN~9 zI|&vA$-t)#^Bww0McOB0MJx(OiQ0YieopK&n?|^9*?dB-iEo{J1ng*SI(^$6P<6dM zUkEUKIe=3eW3hE^0Ur!oVOIAt>8hqNrseQp(@_^diYZ4E{gHq1Jj^V?VQIU83TG-8 zf%1h3HyNw(dgI=<2o(+8j5iM$tE!rEXzYQ5awUJVU)-wMh|ODG`TaYB7rTm{2xwyr za&+X8XS~AwfK!od1Q6f`LH-$JLmqd%;QWyeSs#O|<>x`nQhj<1w_E^5?U({-09^S; z1OG=)E|yq3Tqyt%bKV%m7-Wh2uMbmgG+C^y(wCQZCRTdk$XHV|?>k+{W?o5(Me(hT zCA!{?ElQnn6rqV7t8y_LWK{DFEBLI^X+N1BA^k$>P(STa9)RwP$T_zY<&JveeZVc3 zen3PkW;1Smb!x>t|&Z(3XoaaW^g+p|9 zQ4Z)Sb#8dn7}|A4u|gp1+%v!acJtM1SP9y!Kb)Kc`@i6{v{g#fRZME(`EbV=C%yObc!Sr!U$Ps9XBBmvpHB8zbqWP^eHdqrag3n|M`GryEdVxd# zs+hK3paX^>1wz~wJUEGCJ0b3iu{+#A?4S-|(AwxP9sqQ28&I@IeHmCU&Vtp@6HOh9 zYwyN4jMwZ^Tu#x|%|o(`t51=N=`wu*BY3Bu5o6cY%@D@VzlUYm58jw>lCL$gExA0) zM@G-=uWxg@EBb|Tk}tGpf#5Hd$9jvXx_L~WeW{CZws-brrSo2wN6A9mEW(gKHiJ9l zmr;)uK>(z<3I#&mb5N4rof&5Wg)4@Iq_TWvR;A*y->qebFh1l?#haHAa9%|sz0bq| z+w$+#LH$v&S+c$S-EN|mb|6^R1+!mVz<;KlZ3k~ptit@V1AAYrGK4*-l2ytT3(?6Q z@Fh`T_!NyF1`c9T%ii{CqtK{?I{SUmUO z?NFH?d!rJLpEj?x`x5cEtsmsF;jN-%@g`WgW}mfBx?MCdv~|W~5`|UlAa|!{9g9Z~ zye`hbw(sW8a@>^yVKw$A88bRw>RMeXe+W{$x8)V2P?c zPS-m`e%DF@cCTw5W&&5rtemj5cXR0cC{rEp9@)PYQf~;u_lnyXn=#LQEM8AR8nuu# z&Qrv7Xhx5VCmJHOkJhAhjy&1)C`a*|RRFP08>FK`+$5uV z4}0RICF0xH2&BfUd64KMaZWu=0}7#p4TGS#jLDUAxzBPpVpTBrmt2gkl;{{~o1Hm) zaK*%zer4i_wr|pQ`W0?6zNu4oq67Y!@qZV9$NMKKk!z#;#{M<*gL>VcVo4kM3^DK2 zOjXNG+ln#FcK`C8COS*Kf&JV=&$C=19zZNnJe zUu^c!x^B!A-wl%Jv~FhbwSLv4i8Zw*_<{>bU1s+t^nGNdkE1`7>Zv-Zp8M5O-AW}s z7~rlzYJA*9S5VbjW#0G*cDYFer%sE55d789bPx-X5DGt85Y&ko-q=I>H^S>LT;L3vVoCAXY%Hjd; zeJ(QxR8VSiKDRbgK{&ZQHkQT^POLTAZ}atHqYx3l?1={b0gdCpE?|-aXDRb&^N#B9 zCp8oe#hRvxVJN$&Dfhax)h>&gsLH2M6t*e1#zh8IJf0c`b@fFM53y&S$gXWE%Nsvl zOCFjs;#I*Ogbmy1rd1*k*j)3hd4{vPq%J$Hd8NkAKs#x~4@fG|Na6C;Px z6~g@|;(t1dR6x=x3NjGTBGLc!E0-t+fX#m(meAdTeqrilWkDoGUG&X?#w}KOHj2=p z;9`Aac=292kFe{3oi*5zf5yU37`Dg=!hew_Bu_ziaP-3UDLGf>&p8j>r?N9sFS`bP zz?y?7gY*0Q5BEAPGckPo|Jq0Et8&=N|D~F&J@ZZfOEvMP!uvxi6a`OUB@Ph5@=-bv zzj#ORAZf^lNIkh1Y$^$}s;#Nr(kof3_no*Uixm-G+S_3Mf|+gPBNpCllH@}&677&= zWUOUKWmCZ`zkTn=T3{1^hQAwg2OBIV)b2$87i;o<84no%^;GGgMWQ-4{i}NtvHiwz zb|G)YBLtcD%nXZ9g%D*wqZ@Do1?~sO_YpyF4ADA0=d=5K^$INFVbs+=98ZJR#yn<7 z1rNRsw}5pff#}?P@QN|0S>S!56Lri_lgOhXaxI4j`w!~L39gKL1h(G#`FXE{TW#E+MAr1OTKhR_Q>^M? z*Ezz6y^vt+Mm6qW1(Y_10aS3Viw2NTe{Yw@b5{j#%iKRier)f!5}5ErdfrdqgDQDU z_rUk##BuZLQ@XSiI`1~Y38cjW2N$1AwUdIO!==XxaL6oCxb)^V!$+A@f=L}ujgPrQ zwJH~wjdNBTGRes$gV(8pe6EW?_i|xE=fC(|q6fg^2t{+eisgADbE?w0M+N{@`LLo4 z&RkEB|NV26wIC7yWhra_U+7o;FT|ugO{gTiSV{nP)f%PZ^OE#pehpG36p1GaTF6dY zmQ~}mIjZ}jC(_41<&^SI5aOOl1nPWH(=WnZhrSFmfxaC9AVUxzIDPi4kF$u`5Z!`^ zx8zB1Lgx&NkcC3k4(LW+@dFGtm@wB27|b!W1eqvpbL&^m#1w*t^!M7uSkx%NUvK}4!d)Bo4ina4xb{c(I08Zkzbv9Dt{ks15G z6Ov>}_AQ}Ek}ZsNYza+vGWJr*mXz!vvWrQ!XKYy_Q)r6#P2*RddFJ)Hf8F=%^F8O> zx#ym9?mgensM?)k*PA zVV7XB^gGM`qlmM{Y;|enA9c_9X5^r3zbS~pvonU_whrHS%UN?yd6afx5M{aLp67Z} z*g9t45EtNcuB&d_yGBhO_gWsiKQi*0w;Zoj2mn1kI}BP6W=@>Xsv=aKEq^QTtRCIM z_C9y5L8;j*Gne1HVZm>FG(p(p`m5|YANf$+^JO81b%UHseXx?p*rQ@-3VND6hATa`^K~k%kBv>x^n`1UULQQ@q-CpbZpJv3ev-YCz&~7m5sUQWm_BE{ zMo8T`p)*(lyMti}HJy#zFxW@0!($VD4pWGwg8ot%7@R?6<8v2?FcI4d3+QY9B5rXhpHQ<7K1nIR%sNX~1)Pj0 z0etN=Ot_r^kvnKSF?Zs%#8I$wW@XOHeoy-tLsO=U2pDF?_LGgGBx8mVOgweeZxB34egjnFnimyT3M%7{jKm&<;CM_Fs0jC zH)CEn#~mjKhB2EVm*vzf5mA@WIF;sq)=CFi^OfadO?>pNuROvD8>dkVyY=c;hU-46 zH~LilIs#7|@=acAf%u}nTBmoXo@q%@>?EMHrrHw(+deUrg(S*Fm?CQChf zC6&o8R$rc`;;Jc;#o6AYdCI=U%pr|sz(UE3wztjN;6yY&#H?!GrtRTZmaewx`r6H} z>DM!LUn;vp&~COk|DqhIx9-BV6s48V15UL%0)7(F!_AXUQZ^_kl#6yMVbYAnsdTSs z|5!D47UyIbSH3HN7P=dMU$vAtwQ=`2_Y7pb%wvBze1ZCD;L1vMZNci>;h%OJk7?Fv zAs;hD;<~@;*J{V{&?YmwED3@(j0(|aCRi2ynHsOnPv6Fs>}NV1!@^{;Ox~Y(ihs&6 zJ>Gd{?;N+Q%Jvb|NrrFTti*6ULU?(OLp!VhoufJ;+pFrmn^pK_-3RpTNB3P0wO=m@ z`p#IF`^EXGh3MU$kbE0!^DT;7^pOMdSb31*+40kpteF*11xz34dtU8W$O+5#z>#!s{L7c04ZO`_sk? zYq!$=K1)+x|1{{Z8v)yZ54QXiHFfea=F_sCCuKP$0UNK*20w*znO-$?~p z%8z08G2$pV-x-T}rX4_u7QLm$eUUWkj@rnGl$4SeUf_y=zNzPe96$KNXdT?0U1sUk z!+CoYCK^UAFiy<7`G4BanaUggwYAe_0(jd-8)3rA`eX9gax}|8rm&IGg(|_mGrEn| zX6Bw^uhPp`knKTHkM3kML_s}E6QGtV(yJC`*5an9QCH~;PjkzMtqt*tu8KKX4sWqz zZ(oKyswh`*vUK-kj6Dm7bGV=j({9wYGMvP<{g}MwCYN#VlJ-wFfEJ(C;HqA#>P2%s zzEVz7?}4rE8U953bTl(H?8*el4lcIvYUpDPZwPf;S*_-X_-N1a6voW5c6ZQ7BLzBw zypK^r;Z6Y8YRx%nhHw8^yIlNnZZ**HNNOG`>p08Ijk0ynb8%|)MA>nuQgfx%!;5sp z8Mv(S$?w;^fEQJ8Ni{DF)0S@~0>*`=^70Q`nDsut1@oDwl&_oUA?=F zavrE`Eew*KO}ze1JBhy*Ww(>sNiXHLr$3v>`&Fh16yn7<`*N4qMxY_2WA%xl?w4At ze%%Er`nObl*iG*kD*_e8%7dC$6?z;kBjwUn6cm(xG76;H7{}?Y%Q)rUJ~^4g?1C3Q z`y-oa=lHIO78m4PB+w7fi9mZ1QUY9qT~QEJkwg182S;LC9*mrA$p34-NFCNp=pf z4S_xg{s+N|qP=QgKVtPdgSZyCPJVM+lQzP$P8x-rr#(oo!3+QbNme7jl$kIx8LFsK zg9d&bzJjzDoub2~22rn{sF+)8z`ew%2brNSx&`fCjVCqaq{oIQfX|1hYj(UKYS5&4 zL7@W#uCNXDudIZA?_oi&6+&)q?{wDAR9C>2^df-Z%{A20(wHZa*_ZIz zHV)pVCtTyURsQ_30dDEkT#c?p3EfSryEB?FoNq$s%K~@BZ0-fLL6dt^jms_lD=O{! z0{Wqj412})GQ%M`g}&%G?|F!#cK<^agxlMgBk~aNtGTf#6}wtL?Z^zLX)08Z`MHPH z=|-Q~lv+m8qlJ+cQhvj=%-+b!535Ve61v6=XZx=^W||H8=Jnr@g{tzq4!aw!T3dD_ zYPb5iOkNb3$#5~!xEB?QjZHDYt*@?)CKq{~jw*ZZbscb1Y5+{9skLWv9kRGHbjJ}u zH7P}(RF44gy2U?>p&}w|LL^XbK=UqsCo!6Cuz2ZZrHW%m>*?fosu-MRHAWZC3cUuv zGFL_5FOZXtK8iIlj_9VY^X%C+6T$FJ5eZW~i0V|=+L6jgfTB0(EiLX;#!S#8%y|$4hItGk24PEi>cm@j z)Z==c+~F@XU}bF+_hG>6wejb(b@`E2>s_Ox~Xi8VUDSf_#_lvJS2?Kf}q?!@)F zCm-uu<}1)C8O73@M>ruaO~W4>>eUhNn?b_)`%%(oBRH8sP67#SL{WS%`;7{@AjLTC zBVn0!S|!vr;L@PhwF@RGSv_qPg>I&q7OP!s&QrmrebKU0#}gTuC7(C?l<3FSpdWyr zS$S71ZQ#4J$G%wFpxQ>>f2e+yn(_2!j+?`m_p60_n!&UKKW`ivKUR~pn+e8G# z{Y;o>G5L9etBj4s{Ch6X)AO~!cMGqhS>dY1llN?wjKXp|-@Uh3oq|B@g{o-~waM@e z@ppIAa0E2(Gkl*fx2J{R>%PHE%Ue~KXP_B<$_i#?)!lxAuk}$=r_F4d-CUaQ`KZZwI4}*f+C>#^>t*Gp=ea4c`jBJ|xCgwvCTIpPj|epp>UR$Eq&u6SJ)& z-}rK=)Yy9S$ts4vL3it+U*qQd%IU?}dVDY6nb=E(8Q9pSr5FtdO@f+_Ua<0@Xhd9x z?azIXng@2n!Gka~=ptBR zbYZNMiZh5ad7|>#opA+U9;2O#e*$}UIE&4j%T(Ee#nmD{k@nQ!SA+~DA|*p&ik3PV zuW+>p^brtlfT= z#VU}d%q(=RYDvL9emwA9=eUu~gGR&P8iU(v%k_FujyQu&;GzkA&X*D9#ymsX?*()q znTo`{Xx)OFa%xRz4H>_?LGk&Cu#47K)s3ctxhq@I{V1WvA=jl5|DIs3d+ur z8ql2|Ixb(49eP_jJgm>z>LetWmc|koiR;GV#Fpt&mu3XhYwYz>k+EsCfqP_&6xwH| zIfbOBm<|Zjn|F!MbKsqI8UO%ziIIGu#dUIl?;(sdW%MB(2mmRa)ZZTy7-O2xg^>jF zVUqKixc2Od7L!QN!wFK-QDF)&n~4lEq=s zp(4TGLiYs8I$VGKdr75Npa{4oa=_7Istd%q4y{`MTf<*se>fL0D7g}UeL+c@ND9sP zQ{+m-6pC~GHgWVL0bQgfQ>TD3CCSiwp*+{W%+CJP_>l;VW|1cMU%MgTf3Hrr901cQ zEA_iT++?#H(uICPI!;l;E$WUm}8ULZ^Fs=stWX*r)P~)edZ=e4|12LRsywv23TWV!2N1s?9 zoH)p`#!Z$v+}8vEh*QWc+y0@YxDUSt1OQ+ZRQJvQ2YV>W{rAf?QiDiPP>bPzC?Mu$ zFef?k;rc89z(+yXM*g9XVKyJ~av!d$0RX%dB=`LTG9i)tx8?r3ibN^_<(=N2en4{F zR delta 35498 zcmYJZV|d(c_x+v5Y}`!T*tTukwrxx}vDLV-Z8x@UyRrTBx_`(2c;3&e*?X_c5Y~Jq5mmz5jvSG{Od97tU`OPXP+>qbcF10PeF@RU@oSZs(ke|NfDZC$1zMoE*m!hNL}YBz=hV=S$^_F>V3I{gY7)1T%b*Lnx@~#5gFTC5KedGdUJTX`UrW%qQmhgJV$(hO{xU6zNTUMJgGt`)XFRm@RQ=cW0O63)px9 z;W6Pcj?_6R)iXc7PLZHvXf9lxCU;IuUoEn+7PZn!p71Su^>H(1%oTHI2pI#Y^n%nF zrIpLvVe$LElL_uuzCpj$2*lzn;+OhC{P&-M_d#wGe**(^OsWgQPs(B&g>L1xAip@(u6SRMdf$&AxlQ>rKGo|OOIl7tgU}z1gTEiyns9@?Rpsx zUsCN~HX1|iSCkDN@xnUHQ+}yuD%HWGA@;BPrk%5U(D^lW(?u%^2?XyA0>cB08gU@B z^BNz9c`X-2C1VuZ9GdWUK{hp+q$y?YnyGdKkdRWD#Eib!cZ|`lMAk<4s6nK3+cs<* zrYnXgJrsJ_TNJ&zX~t^MyUPCAc^qj58cZRw@bKc2H>zszL&_t@qJv`e`Y_4EjQt5b#Yh=XD_#a`_O@8utwrd9tdbS3cL zF4~$_%h`j2fi)xrq8k7&L=6MuMLaKW8AxW(Q!d^P)Z0(NHp2FUegjj&fYC(s?}mzg}(-{(!?H=ElbA ztMdcc>@N_kaAiPh9MT}nXQbu*1YF5^WLu#(Y0sdrpsWsF)+(T$(M6b?0Bh>m27=hA zC1>$8ZZYm~DINWk4niDk1$D{0_xznD$;ROkFI}jsE>(zgkw^!OaAI!_++~3uw`y0VJXju zGlqyX2_TqiG;kcoCgiNTzz@^!S=~O2?76x>N97>yG?{wGgV-PV4%0FZwH)fDw0D@; z2$4gBn0@1K55bSY_rq$0Gd{SIxQROYe$55{~Ck4buZxw$6j64YDEFEdJ;Cvc* zg39!R*fxwu!k-fMXvVBwg~f?B4S3vGoV#u#mEUX6K(o#`9*!Il>%WWv=ioDjjHIo0 z29^OaYdN*V)&Z==Oa=S=1d1Y6b2KI+L&Qs&{&J-nokwsJDk@g@OW5N31O-|GlWv8U z6SLN6aAx-ja+uBN6eActMh7%|4#}T1_=+G~#4{P+%jY4-rv1z!NVxjd++Qp7cqWC< z&5l9m2HISAtltymnr^rMOnxt$8O@-wMc)TJMQ$^_9Yq%;c?29u)mLJ6BS-Z7w{A9- zFzHD`KkR`D^M)Ay`hxIHlvp?Z*qGS1H0U#2zc~Kr5dU_PW|n2WYEP@08=rIk&GgD5 zd*2`+h>l5eb2~XeXvV8&0Vy`||Zc@E)^4JPl=KENu}M`yBlQTSYNfDj=;H&*)xi3VR%qpL*Nw<26fRh6HF&DA zfa4Cznu`+h$!K!C=>Y4@Gf1zgb#e2GYR5rzAvP;!iS!7|BnG6@-{X)hni4hDbDIL0 zM!ZUn&h|2l;6c&*sqdy>VVU=Z>tYHz8}6Ofr##WSj`iG>Ay4 z^*r?xF<}l7Ukl%(;9rlUCqfJ&v|}+L5QspZ>~;o+GbziEJPKCbJ0uprV4oB%{Yfg$ zkp2tKa1unl%w{q?6H9u8Tq8riS)D-%;U_A&Oi_6GpXB?Tug!ufzjrX8!OfjOHzNb~Gut!iB8VLi0?%}7E-oBYlEjPh_=Qa2Rn!<(MD4)9 zr51V3?Z#-xlXuduDxT9+o7CEmia57f$z4j3<88BeM^Ii(LXswl_arGqT`i6Ajf%c+ zD^fbrSkD`?%y5!;$Y9y!zD@`b_@-|88$MfI71ds(Ete$zqprErQU^7m@Q|#@FSu2f zMPWX0R5mNUQ5EXRZ+mc41JYx*C2Gv+3;>6J7aD!b}1VBgk-WY~9K~#@-P$PRD~jzEW;} zt+_U1vZ>Bq0nE_O;=NF_3tLb7%T%S*z(Eo4mRMNPt}7g1bXPfq`S*xrB$YzG9h^pl z)<4kSH~2f~r5ge?v;FIpy-+VL2UVT%rg}yiakOv^)eu|TB1|1EOOQPu$Lo@jHiA%9 zVnK6jF-Eh*uENA=(j+C%7)eS*Cg@E=Rp==Pay5sYc*OB~eZOHHLo zF7bIs=Itf!{INBD>nX17qKcv(Aktgk%@uRa5$$iUppur?m|Al6m@z@|AeVRr7ECn| z-{wi3n6GSEQS)ZH3?~zZj+LHKa;@XcvQ<-w08^_l+8M0l9R?kA&Gx-5Ol*+c(I^TN z)u^^LR7akWFkz{d6YCI`;mJL6`;F?XJL@;lMX~k}b=s$cC1z$@@q?cRvi@8y-0}TQ$=kwHMlq} zWE+41IZYvxmVx-ZJmoF>KNL(`~l|g1~ z5WOq%9GweJ7aAf{JFLwQ^A|-b+y*pfAR^YAD7T)8m}-=?I=GnyXWkKPGE&KpmY_sc z19w`ZkjSXS(b~K@M@pqk-`{D{uoGgkPL^ED;$wn+xhM_xJ1f{qP!y>W9*?MlE^ z>LwJI*gI_wx?)ZVJiS2LH8GRW*Vce+yc6Qv*)^JeF{dnAmQL6_l{aP$T-P$THA{2K zG?HTGsOS9gO;x?{b*nE=_&&H85J@e#C7Dvp!i&Ma9Gw$;V9_(JwZ&@*hlqO{3*ms1 z|0lU$!jg{cmW02sz>nibL-5V}KM7(x?hJrqn>T@KfKX#oe@g?qAZ2e($bjWAj5g&FJIJwdX9s{Lx-OgCs zl?$896?SNJvm-S^laQ7hePhpya}tJ6U|idyrsfaGiuWq)YQ#!9&a#+%alN zC5KMhx-rI;Up4t6x=rF0fCU1WIl^bEnt#&WJCcP_V#%C40(2|$Ynll1-s)<0xWz5{ z9`co!m3K(N)d=Nz;1iW+mAW)fyP4hsN$*b6Y1u9_hurjj<;QX0NvcMz_&_45aZo_u z7y9CG6sdl2rco^ z?T<*1JH54VCQ65#o?o7DUcQ0HP4owNyzQ?K-$3%IZ=rF*onJoOAR`%%>wDQRu`wQ7LJggaQPzTwg5L@Za^j3b@SLfCd9I{tou<8bUx~fWg8hRgj`3eG;hv;dK%MukHcl=IBhAg_>G)NJ={f67_ylV?thzOHBUcC# z*Mc*{dIW%@YR0Fa99&Ca1^y*uexVy(|Dj%gEUWf|_hRKHQROz^mWOYcNTPN;h8jP# z(UvO2K_;rxZx;q5>OJPR%DLY%s{7F&KANcO@WEGww+_Eo@yWNs_@(nAJo0oU#ckTH z&6Y#zlN?z@j@{Pyg=|{ZRbP0f-{3=fhG}Ee?xZ zKB>6w7#_R4H8zFv2+3MJ>m&X}vl(02d*j)yoei-e!KdK3Ipiy`1uu}A%^vo1KkJ&@~hCRz7apeoHO)RMhsT_G{0j>ECI8U+6CLK4n zqZS9(CmqCCP4B}pg=y8MMRL4Q$#08JflQTQEg{i#0`r3%Ex3k3v!Xyz_I8WMjyfEU ze?|Il&jtc|dE}C^NNs_tHVX5K0RQi}$uUmY9N(1kg6wV0bRmbtMo29s9ccwgpF^|U z!jvs}oY#m3Mv!kfo`sOoMA3l(0%Ona5QnLia`HYoPhPoC&RdU9xr9An(MEy0ccY`) zuLT*$uicLDXVK+XrBZ4PYcR!wd<`d-?sY$5)B4ahFikfOtBZkG@P6BLtJT~M{d5|n zix46WFM;N-I4`54N`AIMt;~LhJu3CI;2V0?F>~sipi@{Px#6Gpijrx@s5t}#gz`2} zPNn|kFeb1ySTf33HF7eHvY15)%%lvO#6>#h)^(Qa8&s9?%CRyUBY}$$0>=|D^b84Z zaX!LA@kCmSFCd@)&fbPfyte5QpbdWC!q z0skj`5F?IosTjHlWAk4K)W(p!pEyE%!rf&td4os8UP7VLHFJ!h*p)E?fdi^29&zi< zyJ<7_&m5r$x>hGUPNg`Q1CPx7-%rye z;EY9$Dt{u91s z2%$RJT9a3Bn*~#%C?dSk$AI?C)of`DQmP4SdlN6Vj_k=!y|pPbcJ|bzdc@7;xuiZ6 z3DvY?xKp5FWWQwSZ=(b8eHv^^fJrGwNQC+lh42GK_T|u$}lp~$rbdDF-Jwwya zTjh3te?>`vUM4)1P^v%xSmPFZqk=|1mbA^uf8i15m}Y8vL7&$VF>U{|u?&>vD^!!Y zBD%FB4Rx2Uj*9Pgjuvdo`eX3lrWZkCldSxMKhjYDW4ADr62M-0TqYF!r1%-Iz|bsQ4RQU^my>Mk={$PZmfUuw5BF{U= zUJgfI=9iaWpAZ4xD;b|%{5}eNFb8yX^W}ps1QCNC1P`=`Fg=);jZ!HjJB30wh$BSo zMVLgLti~sx*MSh#wACl&F_}*e$YYfI(L)mynYl+AqR$H@f+@{&|rcqA;0ZR!=_c>J&}{K?*orclanXd?wtc{`{dM9yDA7Be*157#GEX?`Tr2I zANctkmgBLTW$l=Uf zr{=8q3Oe>6Vc!RIt`p=)R=AV*d;B{xQUl4veC;{t)jSh8uBiI(hh3KdAYVDVwo;mT zK252?R{eZYoeUX*Z@+L#$-90Nq8Mv|zJ^X+*aGWN$){%nNIf~dVlhNqh3Kh}y%|dg z>b*|UYrt$NEKQ#)vwN!^=d%e*er$yg)!std%RtW8!y+bC(KZX?9zTlfxO1s1P~Ohxf6^BcI4Mq z&tL92H0qeQuRLUK&-g(!f_yMU7Lja?A1SUB4)T_Q$m`K?X5!7F0p4C;0f zVZ#3#G={fBa%&i=m|%f(tZ&}Ra2K-XGpNY?uvM`$#6NbGKj&WQHDnLA`W+5PPA`)3 zh=J)BM#Dr&ar3{pq}$?w`u(4{MgEap{GwAiJO8*27-zT%KI#j=DSpi85AZZ z_`o;OB5Bm0LH|$Ov7MQcuqnv944gy{e_-wlsE3FYA9e<=AH@u^YiW_kb0#4!_;R4~!}h3g3xVl9n}^i> z#oG!&$xjfctoc<{Zgq81ZOsqPJv@q$;97Ao=Lh-nh2o9M6d3sVlchffb!-Hdw1uKY zzMg0qp;Kb9H3J2TgrVh3k{IiF)dBEi{SZmTy2EzL+`H@|9j}PlS;r?r5keP<$X=zb z@_qX!XkwQ_=|Wx_*6CMFl)xq2<2yzKLh2o%P@%HV3Mc(QY>Q=Oe|#dT)%Vsb?q0*T zt z27pe=goFDJ!-q$-ZFbQjyv=TG<`4ZJhA%YSLniza#ymwQhD&Po+`!^tK9$bm^7Q%o zu}=};HNou5&*`c3S*p?2L;U6m7;ny(nKV|z$JY%=ogH#K3LCQrWLb$FGM7>4*j6luR!?%rl&z)cA zbs4{u{gwo1gZ(qwMon67&sWeU297xZiESk>NJt;8zl3nKkg)F` zh(n2xaOf!=L`9IjQ#giZuEL+;;!=xLp2Se*pWEJlg!Ttensrid4UbzBHnhEaVWq=C zP?vcT|G*(G1ZNSfs!FZPzpJNhEiZgp_re~tc(Bi0*&?;-(xBQtKg)Clz* zt;qR0edJ4%9)H!0;c2BG3WG5;Uwn?P5@^@$x zNxDk^`M!O6zexXaXuVnc_e0r*n}hdk?#!2xk9ohw%k`=L->EFG;|Z=SuDeUZulHLV zL1b&hX#&2C6Cg_N?pkN<{j;EMW{k%WTZb~6>?L+{3b1^o{bvV>Y_FbBl-Xr*WBtt0 zruJYA@`Up|X7-IWmD=uNGLhfJ{ezngUkTj#ea%H~RXREL2D5_Oys9QyKUyDCC7Kpi z$o`y`>DATQ#hJpXG0`U@6XRb#3r&zmAW^(!52_IQV_4pAV0xZcCNdOEA7}UxEU{+^Dw3rUY<9uI z`S%QV=E)3<${>J96uoXgs<%1abI@>C#yOz}P0-UU&JZeI%+F}QxkD$=(!fQO!FDzO z$S?J(TSsfZLV6>CAS(RH+hg^QK z%73szbu9!|593dFS$Q7PbuS4M8~4*K?-C=8XiQOJzTk=34^fxf4(Lpjo+QiflsAb z%=Yk}(Yv1h^W?$o>9ISn`Q=esXEfP@FD>N?{wW(aHgX3Vbo-pX3G`-xfZfZ|T{IF3 z^p4{zheXD)Pj34Vrie^Z;)tq>hm9yB*?fkB;rjU(yZ}P1P)qch8Uv1SM=>M zi{w`rl@q3Nv2~2$2hYZQ%hOmp4X&D3%GF$g*6U_G^H}+Tbqe;btn{upM?{pcK-W)W zWt)HxCaM5atJF1V1CYmuSAclo=)Jp@`$R6ptf>?BtOlnhp4L#_fdc0{5aIy6&*yw) ze`VhUSrj+bk@oo+YQfSMDgsM#saIhpa7yjk07DVNFO5e(7g?8c?G?XU+8yr#8@793 zSJM%WP@um_lX0yfUfV_9{NRMptG|%GxqX}|MNK* zAULaTJa)(dKyCrMDo>>3sJeLoH_3|Tp*no{TCPW1o|=>Iiw1m09QIiBV>2pOC#p+d zKg^)Wi`)IBhqm8J6UUrWI_5~M29e6wFT0_-ncb+ZZ3v*{+Bi&Y(F*hEW8Oe#48ywc ztL_Oxx(&-Ipyvs7PRJi++CXdh%XwCiyc;>vE!NN6X@giY)M1*_I=q9M+gh>%0GUd{ zG9$X(Q|%T;8v!9PA!oMZ^s_Dnm6+iyOtUa4u_3@8iouKQyTn}FtQ3SLhs02CprN5I z?LYy$P7)8c60W?nbGKC>pinO*eU!8bV(-)y8aaSec@o-A$*%nkK0h4kUziQvE^kv{ zcWFV9NJsaS*eT{7b;#?I4+_Bsjen6Pkh`l7Wv%G80ss6pqVE?GQ)WDa2nl5vz;d5d=;_QR9hj_q)wAu(q8Ft zv+V;&(zxX7REG+;cq$^|$*eM>EVu@hEyI~SI~y6=QN?iu9RbQQGr2rf+LelNV3?=E z^@lwMFwaU^GKw1)(UvxVr;upcKBwJ12X_=>Lrgrxx6lx(f|)9As#kV@WGYO2Q?vBL zchT=6Oz|1~IRs~VU0#VyyxQIBy|+i@w!GlY^QQ^idUEv^OuzB6-!m|hMBA5?P1zDF z#dUE2OEHw(8FBYVbNEY;HT#p%Mcxo@5P6O`@T^GKq~KC>q`oVD>TV%`A6rmAyhG-x zDau>|I!s8yDdo~ycJ@-{{%dDAzEyt&2MtToP7Q}f519>evw~B8n$PVu@QYH2^N+*Z+qY<977=cXVMbu> z&Rv2W#NT;$v8^K9L8B{!(EUxe`Gj7m{Pz*Wka#1((p4wRhzpEW$c*6cKaLS?NG=Uq zihxBDZ)!?wggNiY=eVte&v}SqJ@2+8*9=$mh3e>8P_A8z=ez#+A2+l;5^ClBXD_?| zwX=9h13WyyN$Gw;l+UH|vZl|*vqh2b`NoK9$;x6Vy-}f&K|4+z>BTl2Qmbvf?Sc zG@)RkqB{AAN_i7=2~+%Ooy`W&danJY~Yxkx~5p#TKpe%^L@r}O5rB-ue&T6ub<(B+^b zCx!Dd166cf=EX5S+1V3}CyMe5vFszYC5es8q@)?8S~F&Z`7jt~0&nY6m+J+wJUp`F zjYhsofr4*qyS+)7Jf~$mZ32m!J zj7!4D%8Fl;Q>?rGlfIiL+yq=TZu!WHW{y!hG+qe@t)0FwUpHjAORPr!JlyTL3~or# z$_xTKoJNTjV4aTmp?E7#`%bwiT+I8}VS4M}xYDFF^jN6OHD!F~=2Vx14D9x$S=X&R3Qn%i^UsUg%9C&9e$(t{d9{+IHy2 zn4sQ1=Oe;INItCX3*O(&vOC81X7X1TUr4T672DY#2OGIy-{-p-B{gg`vRFM?ss4Qz zJZYt8jP_tjcbs%I57Yl#d-ptWU-E;C(S0(@*NK^v%zew9ZPgjXd{^xCdLL1=pRqGu zSuPxR@r!Q^?VMb%qf{`$LOehQ_Cv~gV^_~!BKHlS^c=?4I)wje;>X?HL3z?Uk1}?e zZ|cU`9YNU#8m~3Q#_S;0ooR3X*vYrarbJn8GKV)EYYIObx%$<3q;L3%Egx5FoK0uj zvC*Ty``&fC&3MTsV?W;Rc?-3avwzkTTAh2sBjs&5W~n+XX#m%vD6VOUPF5eAcMts^ zHCSe2EdTcJok&*FZ&LguL#hAt@jXetnh9v8*eky-h~lS%CM&Egndcke69{V(T22Wb z5~AosUA`uL-?=55MRxAiXe06SAKew0VWEJ8iN$*^EjNiOhk0Zy@Vc7JT=jZ&`2sV< z!UgZCuIWLl=@TudT2*Q#EO%{P^$4|u1IeOvGREdVi= z1fRM=D3-u!nUa$vc2dXq4p#YWJnYCOx)Cj_Jtmd9(Z%Gdy*R9~Wc}%LkV({(FK+5;|B<4Lf*+yp$HSS#3y)#MW&nE6a$3 z!|InM@6ZC#(2%*h+6{Oi`h?GsCXT2f|D@cfg<`YZBTX9RbD83Mn(+O)Iiv|ds$}%# z=iRXLL-f|gi@$`?2?RjK2o_3_W416a8YMoC?t(CGvNjImkMjVDoFh>{Qsic6-NOag zdQMr-A7k|r4lXWww!AL7^W4kG@I`p>$X>0N(sOkSqT**Oc_hqjcg~&_FO944Z>;eA zlR-?XJXS`KU5SwZ?Xrl1mFw>O(pqIPWgX^>ij&}7E%BqGiK>LId_3Rj(hJp&so1@| zKE|GD30`I0;o0>qez5y(hORuZcaQQ?fI1Uy~5$j9fnrPE}*ZM_0rGAZWK-Jk{g zUBCue4U~SYEb|tWr~Vc8*@aA~QU0r64K&WQ8tbz>0Hs%6^C|bl6c?K4`>}Cq%Ze;B zwUgGFln@U=wdG{uN2|cf0U-Dgm^l?}DKFh+?{AdTLwvG-KoC;)+ZgOhbj{7HzvynM z53=&4OdAi>YQZLGFOPsP-oVZ>tX}uKdj>2IX}*I7(HGinVwmg+_NM^Iu{aVIXNi7r zDgyrl^h0V&M}7ZBTkwCQwULklVoB|^JJyoaXnZHGM;rJ?D*x}f&|W}mKNOj10EYj@&_L?dBG=1Y3tb60#K(9gbUO%Av4$;f*!Y{%(zsF^ z9E6tC9llqUw43J&l$8VWS*J%^jYtVsx34HPHCA$FgPdimM+~CPD@}srLW41_%Y9Ez zOLe`Y$c03V?h>5H`PDUTfFAlyGXpW?XpN?V+AjE*mrc`OOS#!!t4z`3>sfC4GmV}&5e0UwBwgapAF4oZM;KP{?=J}W!pl3%~&Yx=(xiUBh-LwLJBgyM*q(p&sy*K zW0M4=oPbIW^XeF%mvakMPBPNCl&bNI1+;K-A_yUsfk^70WVEF+A+ZGVI^4G*r}LHF zHTeXT&g-H698V+U_5GZLC1tz0A@ro?FV+iPh=NS8$yDEe!s|HZIri-azCYa6fTdn@ z^?M)_RBBwM+u7T}ZL_DzRhUd=s?VHv5X5c#0Wv&*>nQ5ND_kqin5Tu2RnSycFS!$ppIIa^XeNrJHN|bxuZmJ_p(Yz?eowzfIWTvE(RkE8W6iV z>st-A9{mAv_X!IK-a<6Cim>^|U9AIM$6^nfDaO_lpWcL1YoOP=u^itH)Mj_u)vROyQRDU;i)36-*hRQ|+O<~z-5bKUjL|*V^GF14 zYBL`9Ie>J2xi~Wr(NP!746oye8*B@ ziI)%>9;Rf*^Rt-7IkMuW-cwn%xP7}-WT~gR9O!EiwG-$~ft*~=`I!in?3U~Z1WDe~ zTnW)pEogyE98k{d2;2B>2K8j_7rSO0bBw%!a2y@XEAq;n7>7Zz@a3eZeXyxWc!NyY zuB{`C=~+&}&VF_V+;cvbp_lOez0i6w4Ey>zBbn5MKTfm6#|jzqNXZ?9;j%2nZWBKQ z%Rs^RU9uH;AWCrAA36pC{dx01o?pg1oLOtV_w%BAaB7n@*SUaVEYDxKe(!wURG%UJ zJq0PVERTN+HVO>kS#-9G?XP+_`gO&h>^*c+?$K4@II`uw0-TvL$$`|FTm7ecwB|?D zr^l3L^BtTXQLw;)l?MYwE6m*8q;2PhxLiZoUgSksADM)v>YhKAOdK6NQ8Ef;)7V+U z2)R_KXg=ST(K;d@2G~z4X!|j#y|c`nv);MRJd%8036rb}KmV63;#m1zssAO5ga0K9 z5;+mzy7IgthF+MC46Fv~04|Q=ys*xI2$geU}}hVnZB98 zVcXroF=sYJxy$PF2wF8|ifIgbk^K!+{;PC#+ni%@Z0jM}_4^@g?fZIN%alB2mt7O`-gq&;HL|Dz8R+RcC7Cz)8Khw7m-o=Blf#Qx^t3Vx6zeo2 zQ9g01-}1S!7xrYqIIz#&1Eq#t5QmK|`RJNkvt#Lex>ZVru-Y*#TL)+KbF#-W)um@f zx0HStU%9_>_pp2ijNdRT-+tNoSzs|gJPVRairZ}pc0Db8L>tNaDSN; zfDz1@@=+x0Ix9oNW)vk>>OY0Od?uzJh&OKk{T7J}QPvdyhGOvmnTlFCRil2Q~IfEsYVcxu>R zJ*FHyoAyMj&4ERYu|#=o?(^%cfx=1pL@-2|h4gewqnG368Jp>5=Ik&j-aqvNA|>e5 zlsc&+*I+kUMSojcVIqs(h>8uq@hjfs`#YF(Y?9jiRk#~>=y~aW>fZNTe%msc7I zE)h2AI#+3lql!jeQzJPTEJcQ?8X6ram$@XqY**MNZW0S*%$7vy#ZwNlfjiXJUF76Y zrITMkwf|KZbrO^JkT+x)9jCfM8_49@@4Xg&i*FsoQaKajDYBtx420Waw5BY>;J|4< zEzlO$FeYdPNI%h#NQ(&1?jbFI|9h-79*hP`3?Ybf34Z2*hdrsq%8cMLJ8=rmG!L`Z z)-qLvInC9(eE9?1Cg zVD46{(@u*UF8-!((DNHL;QelyuhjHf@yP9lX}tN>n~;7Ocs5un%oZ%uFgosBf5#j^ zx|}cnx57e`d;BS7eK3L-HbG!-!}@rizY^64w>H(b`M#Fy)}>lEOxk(bBp8dpwyM2zc5$f;#-8vq4ZD-}6)WQhk z{8RfYoClFCr!e>##pPt7PI>VT{n(e%?h7|_|E*P#f536H?IDMii*vLkPRvW%b2a!w z6^RGKx`JtS?l9T-moE9TlqBNDoVEb=L{8>4E>lV$pycp3hhJ_~+;#Um?#f&~9xcWhu5g2- zoeA{0ZFqE6Y^dmiU*-|Ws&hI7VmlJSDp;lVdY=0{14I3L z76mGXd!lx0zc2fFvcY|@g4e&dg>;OP`^HlbN-C;s zfF&CiiOOCY4h}80Zw5K|U|JDgOLv7xDoZ1 zFlw$Yi6Is4dLxHRn)TBJf!X`|=Y#1kZRUvLg8}pKmiQTeXjtd3p?+0u(?fSU={r)g zN2!v24`*)n!`(%w!i#hYI)mEG3_etU!NMy&0AWPtsMo23#R z6#RIygqn?IV4s?t);!&+Y>BdK!#tTpqm#Gv$nw31^6m#E2f`Zp!KSi6v507jD63oz zAwAC25~ozrr%A~G>;rFh=Uv0!Kd58;x}@*KI$C)N2V8qKmO(R<@x`QVz>sZ4aCGK| zXC4bBrhE~!&!6TKp`+1=!fSo*OgEAJ>%7uEp@JT}1(*t%_RC#xz=!#_VQ*@sgr>)8r*DJv>t{%-O_ zRGh_{*nBuO%A5EUcG;tKSjX*Wqzum{^fsZhKM}j`x6?k+g+!3(1KDC6MI}dm_hip* zNm@X2*pBWL$yFof%UvQuXG&Y2pJ_nre)ITkigs4_XoV>8KDs7VNKh9_Tig;TL_pP$ zM>uY$DSt)sO~Wt+$xPS0pV~VTfwEJw-(l%@_5qK0ZS~`~#@8yU4OP*9zphJLU9n1* z+cy+dB)=I$_q+(X045xtE=Lio&OKQNnR(*tU%{Hej7pF}GSm)km`8C%@DS^SNZRk z@UQT190g_y&Do zCD)BexxEeSuE2K#c_<_)OJud%xd@qpU!1}1GXEa{m_TR0CE*JazqhHR*#B(U<^N0A9SoNQ zTwzbZ9hPdtr6qOYQcr!@|6HKt1pb+;s$%*rLh-)=P)i30;icn224)EW04frbp(GrV zfftkAS}1=(6g@*L-F~20QBYK5RVWGD4H!v-!~~_lLk*^-BtA9M-P`Tb{mSfa4KeaV z{1?UqjVAs8f0XgIXpG{6FEew_+;i`_cjnvo&tCzoV@crM>1ng}M(;{%K!L4q>Q+x* z)veHvTu&x$7#MzN6Z48Zk}>gRU&e;jCu+o~tXb=i zIabwv>3gZ?F%kErvBr=B#|?;-8#v4kNyS`?`C9c+wPx5f)Zc0l0)W@rE4MO~oW_^oIqBWF(pv@OeX12=gpkg2R33C#W-^elBfn^X=Z zfyu3LYzdc9EMN*(1oA0ctM=KOhO2+LYMsOh`8iw@C_0q9R3Z11oCqvcE;?DcNR@CM zHwu`+EEgUPBd`UG|I+^S%qec-*2w5QcWPf&&qu4_4x=PI4;7fH{ImE1?v0d-C1}X! zaS8VYvd{Ukvx^LJ{J{ig=ezMqLjgtJA2M3T1fPKUFPM7u5!2=JC(NDUcKI$ZXV5?3 z!FymV%kVmZ%nwjY2MDcD`jnI5Tw8w&d>m!9KWFwavy<&Bo0Kl4Wl3ARX|f3|khWV= znpfMjo3u0yW&5B^b|=Zw-JP&I+cv0p1uCG|3tkm1a=nURe4rq`*qB%GQMYwPaSW zuNfK$rL>_?LeS`IYFZsza~WVW>x%gOxnvRx*+DI|8n1eKAd%MfOd>si)x&xwi?gu4 zuHlk~b)mR^xaN%tF_YS3`PXTOwZ^2D9%$Urcby(HWpXn)Q`l!(7~B_`-0v|36B}x;VwyL(+LqL^S(#KO z-+*rJ%orw!fW>yhrco2DwP|GaST2(=ha0EEZ19qo=BQLbbD5T&9aev)`AlY7yEtL0B z7+0ZSiPda4nN~5m_3M9g@G++9U}U;kH`MO+Qay!Ks-p(j%H||tGzyxHJ2i6Z)>qJ43K#WK*pcaS zCRz9rhJS8)X|qkVTTAI) z+G?-CUhe%3*J+vM3T=l2Gz?`71c#Z>vkG;AuZ%vF)I?Bave3%9GUt}zq?{3V&`zQG zE16cF8xc#K9>L^p+u?0-go3_WdI?oXJm@Ps6m_F zK9%;;epp{iCXIh1z3D?~<4AhPkZ^c-4Z}mOp@Sa4T#L5>h5BGOn|LS(TA@KB1^r67<@Qp!Vz z2yF573JoDBug@iPQ=tr2+7*HcE3(5`Q%{A2p%psJG}nJ3lQR>^#z-QI>~|DG_2_26 z1`HHDVmM&*2h2e|uG(OXl?228C|G32{9e%Onc=sVwIV zZ=g2{K5s0>v2}V&CZi1_2LA=x)v|&YrWGaHEe3L=lw}aSiEdWu&2-C5U0O~MpQ2Hj z-U8)KQrLg0Wd|XyOt&Gc+g8oC4%@84Q6i;~UD^(7ILW`xAcSq1{tW_H3V};4 z3Qpy=%}6HgWDX*C(mPbTgZ`b#A1n`J`|P_^x}DxFYEfhc*9DOGsB|m6m#OKsf?;{9 z-fv{=aPG1$)qI2 zn`vZ(R8tkySy+d9K1lag&7%F>R(e|_M^wtOmO}n{57Qw_vv`gm^%s{UN#wnolnujDm_G>W|Bf7 zg-(AmgR@NtZ2eh!Qb2zWnb$~{NW1qOOTcT2Y7?BIUmW`dIxST86w{i29$%&} zBAXT16@Jl@frJ+a&w-axF1}39sPrZJ3aEbtugKOG^x537N}*?=(nLD0AKlRpFN5+r zz4Uc@PUz|z!k0T|Q|Gq?$bX?pHPS7GG|tpo&U5}*Zofm%3vR!Q0%370n6-F)0oiLg z>VhceaHsY}R>WW2OFytn+z*ke3mBmT0^!HS{?Ov5rHI*)$%ugasY*W+rL!Vtq)mS` zqS@{Gu$O)=8mc?!f0)jjE=p@Ik&KJ_`%4rb1i-IUdQr3{Zqa|IQA0yz#h--?B>gS@ zPLTLt6F=3 z=v*e6s_6w`a%Y2=WmZ&nvqvZtioX0@ykkZ-m~1cDi>knLm|k~oI5N*eLWoQ&$b|xX zCok~ue6B1u&ZPh{SE*bray2(AeBLZMQN#*kfT&{(5Tr1M2FFltdRtjY)3bk;{gPbH zOBtiZ9gNYUs+?A3#)#p@AuY)y3dz(8Dk?cLCoks}DlcP97juU)dKR8D(GN~9{-WS| zImophC>G;}QVazzTZ6^z91{5<+mRYFhrQeg|Kn=LOySHXZqU8F1`dXWOJ?NViPE%& zFB1@$8!ntuI?)geXh|#JJC1+G^n$h4F)g-P4WJMPQn{p=fQtw0)}uk;u*&O2z+G5? ziW_=1kTy(!AJzj}de{a9WHY+*SqJ7` z={VTi)3NK|)*W3PUT#5a$D6oyqH%5zjdO$5ICHx_V;1Z)4A(rT6aasvZ{{r`HnxK7 z^fMLS1{;H{o<8j5hz*F@WkKQmDI*Q%Kf$Mo!EpQ)=HV^lsj9KSz->ROVI zrXAI0!Q?WUosf8t6CR*rl382^sU3q@($L~EC(AoyIjS&2(el|I$a*8oAtqGQs+O~huhBCOFw(^b&bol)F zWsp15Sra3v%&#wXz*!kSi!sV>mhe(I z=_Zxmz&E1>i6=yB*_X4M#ktdNg7_G}MVRGQ7^zX=+mQ}1xtg7JN9E(QI&?4}=tP2#z2<7N%zf9rxzynL~ z!MgNpRvXaU69c*^X2(c?$=h&o~Fvv06*{JdsM!gF$KALcW(}@Q&Alo`@ z3h!H3j^@5rFMp8l6-q!cb?1iS$oZfU+}A2<)&2ZoL34kkSnbf=4>qd%guV7zM1p=amds@nhpkK7 zmRJlb?9zYI&?4ftd8+RvAYdk~CGE?#q!Bv=bv1U(iVppMjz8~#Q+|Qzg4qLZ`D&Rl zZDh_GOr@SyE+h)n%I=lThPD;HsPfbNCEF{kD;(61l99D=ufxyqS5%Vut1xOqGImJe zufdwBLvf7pUVhHb`8`+K+G9>llAJ&Yz^XE0;ErC#SR#-@%O3X5^A_ zt2Kyaba-4~$hvC_#EaAd{YEAr)E*E92q=tkV;;C}>B}0)oT=NEeZjg^LHx}pic<&Fy$hApNZFROZbBJ@g_Jp>@Gn*V zg{XhVs!-LSmQL#^6Bh-iT+7Dn)vRT+0ti(1YyOQu{Vmgyvx3Tuxk5HG!x2a+(#>q7 z#Xji%f&ZxT@A*$m8~z`DDl?{&1=gKHThhqtSBmSpx#kQc$Dh6W76k!dHlhS6V2(R4jj! z#3(W?oQfEJB+-dxZOV?gj++sK_7-?qEM1^V=Sxex)M5X+P{^{c^h3!k*jCU>7pYQ} zgsEf>>V^n1+ji40tL#-AxLjHx42bchIx9Z51CG4Iboc%m0DAfvd3@b}vv4%oR zoYZpZ*dW?+yTcduQlxreAz&6V(Tac9Xw3_`NotT9g&r{F_{!Xb%hDPJqn`CWqDwai z4M@7F4CQ?@C{H~rqxXwD(MFpB4!uljQmH~(TXJJj3MEVHkt7r8!^R;bp!H=&%-OG& zONKIOgLJtng(VD0u9%2LuXKe7h$?9lQ^#cLOo}gOx^+ixt2Izmb6{J`u0VexU0j}8 zIs+?LWLGvQ66Pg0ax4n^G+xW-rwp&fIZ0}lI?y~wn^6o3{jj*VSEQ}tBVn1#sVTQB z(l&Gf(sriC0DKR8#{);Sgb5%k`%l#BfM#W|fN5C8APnl5w%nrNi{BWrDgudYAZLGE zQKTzz^rV(Bst!UI7|8?nB_w}@?_pYX_G?9igK?yo0}({MC^6DiO!bB88kijN>+BCQ8v!rg{Yz$`Hf$tB*WdxSPHMMkJ{&p0(lyXx|^X_VUQBdh9)?_2P1TViiYqy+ z91$zg%3%OjzWyY=X^f7I)2-34bDVCEhECAi^YqS9x@(kD(Bto;VDKfgIo-)s_q)d2mr4O;DTUTgjOe4f51kd6T9 z`xa6_AUP*N{jz%!Z0E!Dqq}JlfPZ2EyGN*EoPHJ^rT;z^0vaI03Z(WcdHTh1suHxs z?;>yWLj~GlkAQ#jSWq|nUE}m()bBZ1`Rh^oO`d+Ar$33kry+En{&JjrML}&gUj3pU zFE58(t|p~g@k3p&-uvoFzpGktUMnQ6RxDA&ibYl_A!{@9au^_fB@6;1XHLORS}C(H zi&J8=@>Kw66&QJD@w>_I1XJuBW3_vn?f~bbTv3_J^W1+E?921QNo!MQiLHISD9?+d zP0BsAK+yB?l009uXXMOteoGX;?5I|RG_v#Bf~l?TPy3zGkT`N>WlZRa=k7Vdbz-66 zIQ979fX!i7Wen@lu-oEcweu$76ZXrc&JWRf!tLRg2JqNG{;`-H@L`KHfgY-Lve@vsPT7B0@716|Z$Z-Z{!W zV;qGHV!`h!S>b)rZpc`9J))^79ey;7@-=zZjys+j=U6maKhDddqZ}XQffIbFYn)R6 z57nRGEG#j`M-Gni4deWVXcr=HoNok4SKTPTIW&LDw*WrceS&Wj^l1|q_VHWu{Pt** ze2;MKxqf%Gt#e^JAKy{jQz4T)LUa6XN40EOCKLskF@9&B?+PnEe(xB+KN|M<@$&ZP{jM;DemSl!tAG2{Iisg ze|}6`>*BENm!G2E!s_XsaUit2`a&pfn!ggt)wG<~NoFFD~p(1PRvhIRZaPhi})MXmEm6+(X? zAw+GxB}7gAxHKo)H7d=m&r6ljuG2KX{&D9ANUe9Q=^7yych#S!-Q!YKbbka8)p==A zm-8`N5_Qz~j7dxLQeaeCHYTma$)Fy}ORKS45sf%}(j`4U=~Aq(!-|ZRRXvQijeGJ^ z%cq3itmW;FI)JsU8k4pNmCazDyH9@=bqwS9q)y8?KhH}MpVTd^>?u+Cs!&l|6KH<* zpikOqr$wK%YZ7(>z%vWLb^+m&cCQ+h_MDo+aXmPW7CD|K$-d&cg$&GVPEi#)hPjGY zx|SBxatca)&Ig?*6~uiQKE)tF7l+ci4JvbZ>vQo}1mB z?m;{w?j6>1xBD9F+2p#YP3U>vfnMicQVHdhK1yDCfacJHG?$*G zdGs93XO$LkB~?nFAfNOoRY`xRs9JiG7CM&Dd5!=ra;zY~qn6HhG|^&58(rYoNlP4q zwA7KN3mvymz;PR0%5d!IoDF1vxVxN zS5wG&fEt`JYIGi>i=Fq;YUc>8aXv_wIKNAmI$xs8oUc$5M((w)<+NMQ6{7X7iz)2t zqz$eebh#@<&91|=(KSq0xZX>fTn|!v{~LlTjaOXR{3kx zDZfD5rHpl>gbmAU@|wOa$t%grx`7}nA|ePPsN0Y)k&2=M zc4?uE@gW0-f>S_2bO;VnKt&W3k$KKdvZh@&*WWKa@7#~`b#Kuyw9kqdj%CMuQ9ESPc-)MbM#7}YUL)ZP_L{+s ziDWcU?e8%n3A4VsFYJpNeLjn2bT>CI3NCJi7EH$DX3S}9p>0NY z#8jZt#!W_KUc?R>k@Ky-w6=+Da+_s0GJldlF|P?(31@{B7bweeajQGYky;y%9NZK$ zoyN7RTWNn&2`?k9Jytjwmk||M(3Z!M&NOYwT}t~sPOp`iw~(CAw<+U2uUl%xEN7WO zyk@N3`M9ikM-q9|HZC|6CJ8jAUAst!H<<<&6(6Zvbpj!BrzUo!>VHN3A3 zvo$EF5-6b1Q~ajXENB~lhUA@|>x6=N0u#cfv&w(qgG`^+5=HoNur`2lvR~b&PjumO|P8X;=d`c+z1YJlY7&H@Dz-Rts$X0IYE9kSIlqGZ7utSx^+2hOEC-eXviWZXQ9;$Va+WlHlU%y|f~ zw(|)o@(5J0o|3MQ2O@+B<@r*H4*65)(r^JTq+<*b06XMGclsEElst5dEfFJ;AQfYh zRt}O0CVKdGh4Tk3-(^-{kukZb*3oM$ZffpGMs;jtk2ZjAsn%mND4R~OS73JDbj^Q4 z40{oS&4<@VUYMInc0xxy?FE@$J_^n)b|gY+Oj;8Pk^)6$w9nbnMms3RSr6q(9wP_) zv01|=P}UbkXoS_1#FCl?>&9cjCHOS!yEJqiGd`83Nj00{X6dHFN84%)I z^*MZ=*Ihw5FxD0YSJHV{j!9v(DT#k7##q~$87Dig!k3EiMO;k|9XhYz8cGVPukGe$ zN5@yNtQgngIs(U-9QZ2c^1uxg$A}#co1|!ZzB|+=CrR6lxT%N&|8??u1*Z?CRaGbp z6;&#}$uQEzu(M6Tdss;dZl=hPN*%ZG@^9f*ig-F9Wi2cjmjWEC+i?dU`nP`xymRwO z$9K3IY`|SvRL^9Jg6|TlJNEL9me$rRD1MJ|>27?VB1%1i)w5-V-5-nCMyMszfCx0@ zxjILKpFhA4*}fl9HYZ~jTYYU@{12DS2OXo0_u+ot_~UfZNaN>@w4Es$Ye>i&qhgqt zxJf9xi6El-@UNPeQ>aXcYVxOUA--x3v13e=7+%#m@}QuMTjN3n--=-{@rNtyYd zYS@LJ(G?*np*HILbUeo)+l8N#+F-;^(8w>i8Q6til8Y^NG7_qa*-n2|4}(k<-HF~R z0v*cP7bxlTWNJ1s6#Rz!NCYesAbm(}4qp%-;B%AF-LyS5Q6@Q|V z&Y2ar$uWn(?UstqXy;5$ZOCC_?L$F@o#dk--?Co{)CGEP^73Kb_^>`G8sAN)M@iNKQLBj>QAcHjIw0!1l6{UYd;|bA+CcC#3IGYysWLa4!KA}C zsEV#c)JpJcF~NX9mrX2WwItXv+s%I2>x#v)y%5xDSB`&bU!9COR@6LwbI|OQ&5mf& zL^GGZnOXEOLshxOs;Y;ikp^M(l-^>J(o0NIdbt5`(fTq>p%?cG;%aHXhv=-@!20#xf*q)++kt8IJ5cG{ zff?Sy9hfzQIroA8N>Git>3xOUNhe8nUspSV`GL0DK}<_w!3gRCwOvD~m+Zn6jxTMd ze<_?egr$S1OySh6XsS!0Wh)wJPX+xd11YQ=Mq7X2tU;U;Xx|ObfO}%y{pchi>ryaM z2zAy50_$ltt(ew6h#CF@+U74D#H@hdQ=dX_=OChf#oerWnu~l=x>~Mog;wwL7Nl^I zw=e}~8;XZ%co+bp)3O{Mryc`*3ryyIC*S%Zu;8Y_D3bFAn%8 zNTYv?y_%Q4zR-DvE(Q*~>ec+JSA76q7D#_wFR&HI@z>V`9-)x zr*ME%7~<$Ykd?U8uZ~EqUe&AlGDqP{uUvnavy#q%0y2VKf%UxO(ZC2ECkuzLyY#6c zJTru6Q`qZQQ+VF1`jr8+bHIwcJg}=iko8FEDt(bW8pbOr>?{5KLASE=YFFv&(&IM| zP6@wK(5#jhxh@Pe7u_QKd{x@L_-H zM=1`rX8`BDds3pf+|$)DBqpXr zDP>JcOxubC$Dy60;8(mfG^6yXE(+N*UWMW?A~?H-#B7S@URtmlHC|7dnB!Lqc0vjG zi`-tNgQ8uO67%USUuhq}WcpRIpksgNqrx{V>QkbTfi6_2l0TUk5SXdbPt}D^kwXm^fm04^i66Xn0`pLmnhX(P0|TezLiFcQ{E0~v*cmmAR2|PET zl7Ls>OakCexUmie^yDw3ccuqd5(wV_6?YM+egsV{M=^n{F2a}~qL}DfhDok9nC!X$ zC9WV!U15~DF2xl0YLvS#K!rPqsqS7(b8m##ZA(3F3H0v&0Z>Z^2u=x*A;aYh0093L zlc6LWl7U5kwXW8By76umJat{FC`H8^K@=20LGUu&PPftQfn-}R#6E~`;e`lZ_y9hX zI9nAF8OY51`Q}eZ-alU70BmAj;IZGoXxzI^8QfCba(CUJ?bh5NiBhFyrjpo;k`}RU zNRzb0n;mJrphLl}?MBw!ZA)#b=BA++$<$N1M{{R?rygu>Giw?@^X;zIEZC0p>fBNs zs+h>AIApa)#`0OLH#W958eWTf?n4PepnREhO+ZIVlfZIfLO(RJrOCfDGEK?&C$Y_> z)=S^{Fuzz4!va$`vL}5lXkrYW%bH|gUK?As5mHLYz!l)Iw)g2uVw^> z5BZf)=cdR%GlXhRaaGM3&Vs|i1g~@4Eug>wRMxJqUof@)jOp4lW}kooS{PUqJ^@fm z2M9!-I|6Hyt%6X033waFb$&wt1h|3@lA>hju-BAmfjCGV5h+8q93HYw5uy}QM_|d8 zm%xHt3D{+J7m{e#O4`V2j<#tMr-_uta^2Q+TPKZL38bS$>J__n)1+zBq-Wa3ZrY|- zn%;+_{BHn|APLH8qfZ}ZXXee!oA>_rzc+m4JDRw#Hi1R(`_BX|7?J@w}DMF>dQQU2}9yj%!XlJ+7xuIfcB_n#gK7M~}5mjK%ZX zMBLy#M!UMUrMK^dti7wUK3mA;FyM@9@onhp=9ppXx^0+a7(K1q4$i{(u8tiYyW$!B zbn6oV5`vU}5vyRQ_4|#SE@+))k9CgOS|+D=p0Txw3El1-FdbLR<^1FowCbdGTInq0Mc>(;G;#%f-$?9kmw=}g1wDm#OQM0@K7K=BR+dhUV z`*uu!cl&ah;|OXFw^!{Y2X_bQcDjSDpb83BAM2-9I7B~dIIbfN_E3;EQ=3AY=q^Dm zQncV2xz0W-mjm8_VaHElK@EC-!ktWFouH=5iBgisaA1U@3bj)VqB)H4VK|{N+2-(JHfiJCYX>+!y8B2Fm z({k0cWxASSs+u_ov64=P?sTYo&rYDDXH?fxvxb>b^|M;q%}uJ?X5}V30@O1vluQ19 z_ER5Rk+tl+2Akd;UJQt1HEy_ADoA_jeuet!0YO{7M+Et4K+vY}8zNGM)1X58C@IM6 z7?0@^Gy_2zq62KcgNW)S%~!UX1LIg~{{L&cVH^pxv&RS87h5Dqhv+b?!UT{rMg#O# z#tHOouVIW{%W|QnHnAUyjkuZ(R@l6M%}>V^I?kADpKlXW%QH2&OfWTY{0N_PLeRc9 zMi3vb*?iSmEU7hC;l7%nHAo*ucCtc$edXLFXlD(Sys;Aj`;iBG;@fw21qcpYFGU6DtNH*Xmdk{4fK0AKi6FGJC#f0@j_)KD&L`tcGuKP_k_u+uZ@Sh<3$bA}GmGrYql`YBOYe}rLwZKP!xrdrur z0ib3zAR%*So7rZjP$|`v$!nA9xOQ4sM|Is)T`iB$29KOE-0_Y!v(GZKhMia4am~e# zu5PJbJTk5!5Jn35E$W1AVWB&zA{r<8tP)wo%Vg0}o(EZ}Ts5eMgW$E9nUDxFyhPP( zs8$YB7)%~lUan?sD~~9DckP11Ea%9&uY)hvUwxUwb}pf|IT$VPqb9AAiAuw>G+8N8 z6Ovlm%$~Fhhg1!#<%uJPW4P+L>rOa{&N2gbFd3Fh-nnA8lL@IrHd6K33HFYag|7^p zP;EZ&_CU5|tx*P)T5w<-hNeoB7VAth{E$^zh&!tb9x@TA^<6WYl=|`BSI?aM#~0G0T^KK!+74^cJ#Nj`srvw<<6E zzM$Kx-86sp4;1hc2-blI9c0tmCMY}Qn=5b(4Vqv z{|sKKb)cXA9B?~>#9fzsZ29S1Tr62*LHahw(?8R{AQudS8<=zg^lz2q zD}8im+_uhWqYUr=fMT#sIo${8zZfe2N&j7)tPfNL^8Z2}6)v8;x|<$fDzHr5?L0g@ zAOmYTwm%3~HQmw+c~!W5LEVM>2|z;BF)jd7U&jQ0%D8~=0et;cR2&d~)H=6#Rr*B( zV9$6xY#V}Z4=>PWem5wViJ&4Bv3xeU=0-BSSJ zgLq4Ssb;S7t=xC1%@8T#c5w$=0*}ik;4@vwq3Am7=yuN-b_|MEpaRpI;Cvp9%i(}% zs}RtlP5ojEwsLfL7&QhevV-Nsj0eq<1@D5yAlgMl5n&O9X|Vqp%RY4oNyRFF7sWtO z#6?E~bm~N|z&YikXC=I0E*8Z$v7PtWfjy*uGFqlA5fnR1Q=q1`;U!~U>|&X_;mk34 zhKqYAO9h_TjRFso_sn|qdUDA33j5IN=@U7M#9uTvV5J{l0zdjRWGKB8J3Uz+|(f(HYHAjk#NQ1jL9! zuha9;i4YYO5J$mewtTo9vVtPTxqXvBInY?m4YD)~h~q$Ax!_EwZpqbZI3OP3;=4xa zULDboazx{;=E*zl0g)CIxiwU0S+taYYlIHHMHZAe8xkWHvSjw;0&`NOTN%Xcr-ivm9Bz1h6 zny%66)ZjF=M6S}>=v4~EuG0F;50<8uJ7@5d0V_2pQVkF7Vq{{!dIm33#3Ft_}G2)yjM)! zd^I{4d6C{M=mM$U&yqhi=!uOq^+sms!NF^^FO?LLY1%(UAAuAQ;Js8WHnK=;BI0?G zj@F^p*@W>;sZ=u3l$xf8pzH;I3P)vOmA?n#aMPBi8 z^%0|sj#w@`5rIzhQ!tSbr|=trz3XA)gH(s7qlZqzSnr3GpT_7Etp6(f@@<&&Cgd6@ zO_{P$>oL!s`$Ftx@?LJr&QNaX8kwntH#$vkYg|R22_$?WFI((Ps;mBgX=;jxe4dv2 zB0W9@Ytx5X>gz7C*}oPKd5d(eNI!)2=dpg8p7eD2T72>A&r(Oc#kZr8Zl0T=_oWh8 z{A0N9vXFPx)*^lID7MGYhmW53!69FY@je$)Lq+<@3s5PVD$*r5``M(QjgmT^@OmO6 z-sp%gHc}rSY5JLvw`8Gz=TflG&)tw(+<*mIXdUgu%{CxCbK8#JowN2@0SO=M^#R!H z6?`{v`CUe5FJ?SwyCTwGaWuckZrbd*cS97n*}$HSL^o`QV`u2{Me=!GI9~_dUxVbO z7s|jzu~fEkS2;SKy+&74sr^v1Sfo!g?rt#d&g0|P1t9ae)DZ7~4AaMp^qVvE1qqxl zUZ9nHsoy&~b@Pi;bSxIXMqg&hucX*B)AZGlZ<_wNNMB2M8@&ts^)Xsm@z<+UH@_KA zm7Vk&{!iU}$6y2}y>=s3q`$h%KQ|De3gWd_T4=Rw*ODsRR%(-Nn7U+pH|>$_UfL(y zBps0LFddieaXJBi>k?^{mF+lLvMtd2WXr!S_d)uoY)gJo;16IEvvuH(Z&YlEF~4Mt zgVERw{mtdnP$YGQLX5QNiKcH()87Fhz);ga;3ro8{wMqZN=5qDvS|E7)4xm6|Cyb+ zfwKtysRw&ATYU!+B2TOXK$*G3l~^PtLwPV-6rR$Fz;;o8z>*(s7WJjAq^m9+Eguv+ z(JTTuX-2FlipGi#>xbCfU@qZdcZ!5pBz#h2ErNo*n((t*0g$hCrXHnm|i`@X6!d0j(RK8a`Hw2l5S1eVl@8los!kPhF(7@ijcCcL%PBB!<=~MKK)m z$2=`T0Eu_#R=NXIH=h{{`4iqLa>{Mu8oi!s7Kf(A;TzGAKje#F5l5QETXFpg?7)M8 zD4Qw*a~?Z-8SK4tke9LDVAp2xFf0l}5RJ{^1U}<`@`|I)B2%(-WLk{fsNVS{3NYNy zg}nR)ue=tyK_MEWlVVgDvV8=;&C^-g=a&0t>2a|ceQr0P|8{y#_POQ$^YjVX=a&1Q zq|36;E%!Nkxz8>4U!u>;KDXTeI(~qWgw0KJD zS&EAzCZPW_^!Tj4^T{T!k9N#2;RO z7iBy{i;&QUo$Tz+nfE#GOwP=ozrTJ1Sc55We021t`blp}YoGj;%5y1uf!uNG{2Uc(N@c!)lX%wI3y3q;Kp>H=-52V;i3A7>>%(TwkwPYfo4kR?qm| z#C16kwWU$vA^EoB6NQd%bM%nHh`l&oU46V-HClA2e;$PpNH>BcwCIK7lE8cr+NK@K zmP_V`PLn)Sf8Dbz3|Fu5lWrRhrFHeWUO$ciK|;QNMYU4B-{xxq=2gh0MJ_>CzIO%I2C`dQ0}U%zLwzhCD9eXj z_~Pck%ya+e`Xnf;1j}62O+JMJ**YJ(mx~=JE+{p9z;saHl6M^@O>uaJ(zL z_pbbfg95AEkMI{PQrP_-wu~WeK)#DjC~RTz1jWl>>J%&u_A8uVq z=X}6rk(Ww~N);x^iv)>V)F>R%WhPu8Gn7lW${nB1g?2dLWg6t73{<@%o=iq^d`ek= z8~x4CS6UNrnFvN?(WJ^CT4hqAYqXBuA|4G-hEb5QoM5x6GZPijL*Z>uQZW67A|R9w^IzUkPhic=6Im%(-`|RxlHTyT__; zTIpHtPB288^%``Bpy}I=`(B1HzbS#S^Q*EAx4u+7Zxc(*~GMtIG z28o~(XLX!G7eiM=)yPxBISPB#v`zndJ?z~G&ZAdH4=ynDG-o(tf4fzG(U*c(G`yvv zwG>!)eOpH#E;0lxhZh*mH;kJ6>$aB=Q(^iUP8ycui3r|Rf%`B(*o|DLxmTuAG{kib zs-%KzVslaWt>u!4${j*dfuna=Gjl-rPoCZgwb{OKc%p z!#g#+w~fKv?Jbb;@C$svFq?dVj~E_foIb8G|l?27Kf`O2bZM(f5T<@B@DC9-<3~{+ae-(qxiFGMiqxGcB za}=}LbSblhT0Q6Rm4>3=gi)o*G!B_6$tq*ItV%e0&U6FU!uj0%!h9}SX6NEZ9}oim zg4WPW?76Hk0#QwuQj$)~3QJw+v|eX=>YZgbHMJs34ZXEzFL($9Pw6>LDO8nGd&N^$ zGQH4GKq$+GsmsL%f7cNR?6y=YGgJHdofV|o;~RKj0^!|%nF=P~ai{JLHLCol`|FQ7a$D7+;JWrBjTd0T_>aUBJK||PoA}xwjpy>>3&$74 zTY?_p_n~D4+YZ_`VA~9v3?#|5p?&G^NcjljeZ~g^f18y^%J9)Cd^>|=NijQzL5oimxJIZx~e9?Ss^Ty`ZaDtBpPPoAsJW(yH z$N4T<;S2#yPeoF?lu&qNOqVhlu1EGea_2aYXH89ap^{o07Yne+0~cxtd5_*)sP&)@HC}ize=e%9#0xj( zimzo}crZ_VtzhsLf5+j%DhiU1%Z6##t_Qui5BGbp8h+wH(WFEnJTC%R=pic)GR)Vx zl-NNqUE8ZG40R2ST?P81rl{~1FV|}^b%`m4HAwH{ZlxUX8MfWq z`a@huNpbS?_U{vkW?z`BxS?f6-*EN3sQFIU*&Zs>w{W~F`=`NPBs+to_fDoY%J&mT za^I?KQr#C`SXEQy8+4CjK)^94+%U%c;)ha|&Us}Dr~V$FH5cA?_7^NiWdTjcKHGad z`(I^x!&*O@E(D3_I~YMN_e-TkVO-5I{WP&CB=atLdm8yB{z!y)5Z#}y(N%%2ER_xg z%>LL&rt(sAn)YO0P%RHU_uED75S80CWtD+%&xu7-w}pRI5;ZHbK<9KYH7;`S)ek+G zpDa%oN5mO>3}rcuKV)=g>f?*8y+^~ny)tY#>h#^uo@~ZRbpFILBs$)PO1o{c;%l~I zD?NNElg^LuV{Zx-9EL76vUNwjEx-8^=V(QVq}1|CC-_?mHD&W!5ytLBA2u@@RT9Q> z5xk@ED^7TkWyJ|qISZjmlqVaglnH+04zh=RgkQyZ`o00KZ%;wtkt+1Zfbr_+Bl+UQ z0k+>A>rBUsjYV?}r=%Kr181gVKe?JMLa*1$T0$f4*>!PStFJpzf09sNh0^E@q; zxxBLnmYcr*boFYk|E>f~ixql>m#wNYJy~kwCc7>jYe_nUIyf?5S*Ly>@l+U_dq?VP z9RTLWnacWtvIs>7Y57AP&h{Dcbq#_pqu_*s8@Sw2vPDmh`9nW)Pd4FdLBXGeCH0r* zFcfC+Xusg2SFr7wr{+65-Pp{>OBG?9PM)97r{om9;MAMK?!5~I9v*`C;{AswcJ~!b zGT&%x5>A3vjHSp+G2O>AlDq$n^NP`!N%izEgie}0Udr!{OnOP@N8JywJ)3jckiV=r zAIy?fcNrQr^K~^JY0~WKh|kScq5ZOLA7*+POIaHo0#m2ExXhm0?<#VZ=$Ug#G@i2T z)Sf$}NrGLS=Y-yWqM|SvSP9zLSvaRuaxJ6M&$r&#-Tk{?9%kpEhxL4t#N{sonv4!M=G<)_BS$lkk!b3IDCH4K>9w(_}s{ z8ddu``20Oov^etWaqOGP*3C+sL}i0^-$R1}G540BRvPNA#!TB~s=UQ$8_)3M}ih#tlN-sc}LQC%Kx^SHHG z|8oV-?{)JR67dyM@{98&y>+wY#<#*y8n4xgB#h;ECrY%I_r-W#892Q7vGc~G{wtig zjXw0drcNP>4zj4bxY4C_vy>^`Y-Rr+Pr?*V^|$zY>>6t8$b4oRv>_=^_+Vk8d`!g5 zY-$%J#Z7eEH0yWLg(Gf;{gb5Gb4L^Z7#|7o&1!BYXOnDC=f&37(Dy_;YU*DaSI4$} zdnSXw0$cR}*#^&pz!P<9Ix+j3>9NqNw+#|^GzK6om9@_?wN)~y&PeA# zqf%KAM72YYcGd{WbE}+khGVVUlmoyn1f_9yf9snxn?~zmZbu&W%K*sAe0FTwP@avv=0APWMrl3v(3BTr=2K50s zLV_s_LTqi823pIlPHNz4?6;r@o;Wlt*%TD~7 zZYWe1GU3lu7)li?gFKog9Px9NMH@s!5^W9E7Fyxu!au9JKafKe0!;GyA83M?YsLHq z)z=Euw;cgF_(706e*KjPNI?hz9AQC#Huz5w3Gcmj3JL)95zq$?oT^+z#KT8+pj0oQ zFEW+mQ5e!lGk{09zHtcvUm>Dll3|e5YK&joh=O{ii+}=hV5p_l2*0-J0;NR$Zm08L zXhndBQ?4$_ Date: Sat, 14 Mar 2026 11:15:55 +0100 Subject: [PATCH 019/349] fix(chat): Duplicate message handling and message popup --- .../compose/TextWithMeasuredInlineContent.kt | 14 +++-------- .../chat/compose/messages/PrivMessage.kt | 19 ++------------- .../compose/messages/WhisperAndRedemption.kt | 18 ++------------ .../dankchat/data/repo/chat/ChatRepository.kt | 24 ++++++++++++++----- 4 files changed, 25 insertions(+), 50 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index 78cf9e176..5549233e3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -61,7 +61,7 @@ fun TextWithMeasuredInlineContent( modifier: Modifier = Modifier, knownDimensions: Map = emptyMap(), onTextClick: ((Int) -> Unit)? = null, - onTextLongClick: ((Int) -> Unit)? = null, + onTextLongClick: (() -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { val density = LocalDensity.current @@ -141,16 +141,8 @@ fun TextWithMeasuredInlineContent( } } }, - onLongPress = { 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) { - val position = layoutResult.getOffsetForPosition(offset) - onTextLongClick?.invoke(position) - } - } + onLongPress = { + onTextLongClick?.invoke() } ) }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 8b4bac63f..109121c1b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -330,23 +330,8 @@ private fun PrivMessageText( } } }, - onTextLongClick = { offset -> - // Handle username long-press - val userAnnotation = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() - if (userAnnotation != null) { - // Long-press on username - val parts = userAnnotation.item.split("|") - if (parts.size == 4) { - val userId = parts[0].takeIf { it.isNotEmpty() } - val userName = parts[1] - val displayName = parts[2] - val channel = parts[3] - onUserClick(userId, userName, displayName, channel, message.badges, true) - } - } else { - // Long-press on regular text (not username) - trigger message long-press - onMessageLongClick(message.id, message.channel.value, message.fullMessage) - } + onTextLongClick = { + onMessageLongClick(message.id, message.channel.value, message.fullMessage) } ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 42214df92..f6e0d87fc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -274,22 +274,8 @@ private fun WhisperMessageText( } } }, - onTextLongClick = { offset -> - // Handle username long-press - val userAnnotation = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() - if (userAnnotation != null) { - // Long-press on username - val parts = userAnnotation.item.split("|") - if (parts.size == 3) { - val userId = parts[0].takeIf { it.isNotEmpty() } - val userName = parts[1] - val displayName = parts[2] - onUserClick(userId, userName, displayName, message.badges, true) - } - } else { - // Long-press on regular text (not username) - trigger message long-press - onMessageLongClick(message.id, message.fullMessage) - } + onTextLongClick = { + onMessageLongClick(message.id, message.fullMessage) } ) } 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..9209b7776 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 @@ -513,13 +513,25 @@ class ChatRepository( 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" + val messageWithSuffix = if (lastMessage[channel].orEmpty() == trimmedMessage) { + // Find first space to double (preferred — Twitch strips extra spaces server-side) + // Skip the first space if message starts with / or . (Twitch command prefix) + val startIndex = if (trimmedMessage.startsWith('/') || trimmedMessage.startsWith('.')) { + trimmedMessage.indexOf(' ').let { if (it == -1) 0 else it + 1 } + } else { + 0 } - - else -> trimmedMessage + val spaceIndex = trimmedMessage.indexOf(' ', startIndex) + + if (spaceIndex != -1) { + // Double the space — invisible to viewers, different on the wire + trimmedMessage.replaceRange(spaceIndex, spaceIndex + 1, " ") + } else { + // No space to double, fall back to invisible char suffix + "$trimmedMessage $INVISIBLE_CHAR" + } + } else { + trimmedMessage } lastMessage[channel] = messageWithSuffix From b808f01e84e96c6515e7516ff2cbf5462f2e0359 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 020/349] feat(emotes): Load user emotes via Helix API --- .../dankchat/data/api/auth/AuthApiClient.kt | 1 + .../flxrs/dankchat/data/api/helix/HelixApi.kt | 15 +++ .../dankchat/data/api/helix/HelixApiClient.kt | 33 ++++++ .../data/api/helix/dto/ChannelEmoteDto.kt | 14 +++ .../data/api/helix/dto/UserEmoteDto.kt | 15 +++ .../data/repo/channel/ChannelRepository.kt | 17 +++ .../dankchat/data/repo/data/DataRepository.kt | 4 + .../data/repo/emote/EmoteRepository.kt | 101 ++++++++++++++++++ .../flxrs/dankchat/data/repo/emote/Emotes.kt | 9 +- .../dankchat/domain/ChannelDataCoordinator.kt | 9 +- .../dankchat/domain/ChannelDataLoader.kt | 1 - .../flxrs/dankchat/domain/GlobalDataLoader.kt | 10 +- .../com/flxrs/dankchat/main/MainViewModel.kt | 19 ++-- 13 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ChannelEmoteDto.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserEmoteDto.kt 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..5f2fe4281 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 @@ -65,6 +65,7 @@ class AuthApiClient(private val authApi: AuthApi, private val json: Json) { "user:manage:chat_color", "user:manage:whispers", "user:read:blocked_users", + "user:read:emotes", "whispers:edit", "whispers:read", ) 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..3c3b90266 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 @@ -267,4 +267,19 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc bearerAuth(oAuth) parameter("id", id) } + + suspend fun getUserEmotes(userId: UserId, after: String? = null): HttpResponse? = ktorClient.get("chat/emotes/user") { + val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: 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 = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterId) + } } 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..41c8b0fcd 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 @@ -21,8 +21,10 @@ import com.flxrs.dankchat.data.api.helix.dto.RaidDto 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.ChannelEmoteDto 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,6 +34,8 @@ 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 @@ -241,6 +245,34 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { .throwHelixApiErrorOnFailure() } + 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 + } + + 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 + 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 + } + } + private suspend inline fun pageUntil(amountToFetch: Int, request: (cursor: String?) -> HttpResponse?): List { val initialPage = request(null) .throwHelixApiErrorOnFailure() @@ -341,6 +373,7 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { 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/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/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/repo/channel/ChannelRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/ChannelRepository.kt index 9afaf3cc6..6e82aaf01 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 @@ -100,6 +100,23 @@ class ChannelRepository( flow.tryEmit(state) } + suspend fun getChannelsByIds(ids: Collection): List = withContext(Dispatchers.IO) { + val cached = ids.mapNotNull { getCachedChannelByIdOrNull(it) } + val cachedIds = cached.mapTo(mutableSetOf(), Channel::id) + val remaining = ids.filterNot { it in cachedIds } + if (remaining.isEmpty() || !dankChatPreferenceStore.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(Dispatchers.IO) { val cached = names.mapNotNull { channelCache[it] } val cachedNames = cached.mapTo(mutableSetOf(), Channel::name) 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 853002c26..5386f78a6 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 @@ -147,6 +147,10 @@ class DataRepository( } } + suspend fun loadUserEmotes(userId: UserId) { + emoteRepository.loadUserEmotes(userId) + } + suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) { emoteRepository.loadUserStateEmotes(globalEmoteSetIds, followerEmoteSetIds) } 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 e4f4b2776..d0ab31f95 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,6 +1,7 @@ package com.flxrs.dankchat.data.repo.emote import android.graphics.drawable.Drawable +import android.util.Log import android.graphics.drawable.LayerDrawable import android.os.Build import android.util.LruCache @@ -14,6 +15,10 @@ 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.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.UserEmoteDto 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 @@ -26,7 +31,9 @@ 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.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.BadgeSet import com.flxrs.dankchat.data.twitch.badge.BadgeType @@ -59,6 +66,7 @@ import java.util.concurrent.CopyOnWriteArrayList @Single class EmoteRepository( private val dankChatApiClient: DankChatApiClient, + private val helixApiClient: HelixApiClient, private val chatSettingsDataStore: ChatSettingsDataStore, private val channelRepository: ChannelRepository, ) { @@ -311,6 +319,82 @@ class EmoteRepository( fun getSevenTVUserDetails(channel: UserName): SevenTVUserDetails? = sevenTvChannelDetails[channel] + suspend fun loadUserEmotes(userId: UserId) { + try { + loadUserEmotesViaHelix(userId) + } catch (e: HelixApiException) { + if (e.error is HelixError.MissingScopes) { + // Fallback to old path if the user hasn't re-logged with the new scope + return + } + throw e + } + } + + private suspend fun loadUserEmotesViaHelix(userId: UserId) = withContext(Dispatchers.Default) { + val seenIds = HashSet() + val allEmotes = mutableListOf() + var totalCount = 0 + + 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)) + } + } + + // 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 + } + newGlobalEmotes.add(emote.toGenericEmote(type)) + } + } + + if (newGlobalEmotes.isNotEmpty()) { + allEmotes.addAll(newGlobalEmotes) + globalEmoteState.update { it.copy(twitchEmotes = allEmotes.toList()) } + } + } + + Log.d(TAG, "Helix getUserEmotes: $totalCount total, ${seenIds.size} unique, ${allEmotes.size} resolved") + } + suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) = withContext(Dispatchers.Default) { val sets = (globalEmoteSetIds + followerEmoteSetIds.values.flatten()) .distinct() @@ -480,6 +564,21 @@ class EmoteRepository( private val UserName.isGlobalTwitchChannel: Boolean get() = value.equals("qa_TW_Partner", ignoreCase = true) || value.equals("Twitch", ignoreCase = true) + 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), + id = id, + scale = 1, + emoteType = type + ) + } + private fun List?.mapToGenericEmotes(type: EmoteType): List = this?.map { (name, id) -> val code = when (type) { is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name @@ -693,11 +792,13 @@ class EmoteRepository( } companion object { + private val TAG = EmoteRepository::class.java.simpleName 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 const val MAX_PARAMS_LENGTH = 2000 + private val CHANNEL_EMOTE_TYPES = setOf("subscriptions", "bitstier", "follower") private const val TWITCH_EMOTE_TEMPLATE = "https://static-cdn.jtvnw.net/emoticons/v2/%s/default/dark/%s" private const val TWITCH_EMOTE_SIZE = "3.0" 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 42dc3eeae..7900397ac 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 @@ -16,8 +16,12 @@ data class ChannelEmoteState( val sevenTvEmotes: List = emptyList(), ) -fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes = Emotes( - twitchEmotes = global.twitchEmotes + channel.twitchEmotes, +fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes { + // Deduplicate twitch emotes by ID — channel (follower) emotes take precedence + val channelEmoteIds = channel.twitchEmotes.mapTo(HashSet()) { 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, @@ -25,6 +29,7 @@ fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes = sevenTvChannelEmotes = channel.sevenTvEmotes, sevenTvGlobalEmotes = global.sevenTvEmotes, ) +} data class Emotes( val twitchEmotes: List = emptyList(), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index deebdfdb5..6ceb27ac4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -104,13 +104,8 @@ class ChannelDataCoordinator( * Load user-specific emotes after global data is loaded */ private suspend fun loadUserStateEmotesIfAvailable() { - val channels = preferenceStore.channels - if (channels.isEmpty()) return - - val userState = userStateRepository.tryGetUserStateOrFallback(minChannelsSize = channels.size) - userState?.let { - globalDataLoader.loadUserStateEmotes(it.globalEmoteSets, it.followerEmoteSets) - } + val userId = preferenceStore.userIdString ?: return + globalDataLoader.loadUserEmotes(userId) } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index 122f26b65..0a79ac337 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -119,7 +119,6 @@ class ChannelDataLoader( onFailure = { ChannelLoadingFailure.SevenTVEmotes(channel, it) } ) } - listOfNotNull( bttvResult.await(), ffzResult.await(), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 28182ed89..3a3888fbf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -1,5 +1,6 @@ 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 @@ -46,7 +47,14 @@ class GlobalDataLoader( suspend fun loadUserBlocks() = ignoresRepository.loadUserBlocks() /** - * Load user-specific global emotes (requires login) + * Load user-specific global emotes via Helix API (requires login + user:read:emotes scope) + */ + suspend fun loadUserEmotes(userId: UserId) { + dataRepository.loadUserEmotes(userId) + } + + /** + * Load user-specific global emotes via DankChat API (legacy fallback) */ suspend fun loadUserStateEmotes( globalEmoteSets: List, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt index 1747932cb..9660a78a0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt @@ -470,9 +470,9 @@ class MainViewModel( return@launch } - val userState = userStateRepository.tryGetUserStateOrFallback(minChannelsSize = channelList.size) - userState?.let { - dataRepository.loadUserStateEmotes(userState.globalEmoteSets, userState.followerEmoteSets) + val userId = dankChatPreferenceStore.userIdString + if (userId != null) { + dataRepository.loadUserEmotes(userId) } checkFailuresAndEmitState() @@ -647,12 +647,6 @@ class MainViewModel( 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 { @@ -675,10 +669,9 @@ class MainViewModel( return@launch } - val channels = channels.value ?: listOf(channel) - val userState = userStateRepository.tryGetUserStateOrFallback(minChannelsSize = channels.size) - userState?.let { - dataRepository.loadUserStateEmotes(userState.globalEmoteSets, userState.followerEmoteSets) + val userId = dankChatPreferenceStore.userIdString + if (userId != null) { + dataRepository.loadUserEmotes(userId) } checkFailuresAndEmitState() From 5664f4c9ebc9628d42047ce9bb7666e40eadb02d Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 021/349] feat(compose): Add whisper support and notification handling --- .../chat/compose/ChatMessageMapper.kt | 8 +- .../chat/compose/ChatMessageUiState.kt | 1 + .../flxrs/dankchat/chat/compose/ChatScreen.kt | 8 +- .../compose/messages/WhisperAndRedemption.kt | 50 +++++-- .../chat/mention/compose/MentionComposable.kt | 8 +- .../chat/replies/compose/RepliesComposable.kt | 5 +- .../data/notification/NotificationService.kt | 23 ++- .../dankchat/domain/ChannelDataCoordinator.kt | 11 +- .../com/flxrs/dankchat/main/InputState.kt | 1 + .../com/flxrs/dankchat/main/MainActivity.kt | 11 +- .../com/flxrs/dankchat/main/MainEvent.kt | 1 + .../com/flxrs/dankchat/main/MainFragment.kt | 2 + .../com/flxrs/dankchat/main/MainViewModel.kt | 2 + .../dankchat/main/compose/ChatInputLayout.kt | 81 ++++++++-- .../main/compose/ChatInputViewModel.kt | 83 ++++++++--- .../dankchat/main/compose/FloatingToolbar.kt | 107 +++++++------ .../main/compose/FullScreenSheetOverlay.kt | 15 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 140 +++++++++++------- .../main/compose/sheets/MentionSheet.kt | 136 +++++++++++------ .../main/compose/sheets/RepliesSheet.kt | 87 ++++++++--- app/src/main/res/values/strings.xml | 6 + 21 files changed, 566 insertions(+), 220 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index faecc8eef..ac573b43a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.toArgb import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.ChatImportance import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType import com.flxrs.dankchat.data.twitch.message.Highlight import com.flxrs.dankchat.data.twitch.message.HighlightType @@ -134,7 +135,8 @@ object ChatMessageMapper { context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, - textAlpha = textAlpha + textAlpha = textAlpha, + currentUserName = preferenceStore.userName ) } } @@ -408,6 +410,7 @@ object ChatMessageMapper { chatSettings: ChatSettings, isAlternateBackground: Boolean, textAlpha: Float, + currentUserName: UserName?, ): ChatMessageUiState.WhisperMessageUi { val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, true) val timestamp = if (chatSettings.showTimestamps) { @@ -483,7 +486,8 @@ object ChatMessageMapper { recipientName = recipientAliasOrFormattedName, message = message, emotes = emoteUis, - fullMessage = fullMessage + fullMessage = fullMessage, + replyTargetName = if (currentUserName != null && name.value.equals(currentUserName.value, ignoreCase = true)) recipientName else name ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index d5ec053f7..5877056b2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -155,6 +155,7 @@ sealed interface ChatMessageUiState { val message: String, val emotes: List, val fullMessage: String, + val replyTargetName: UserName, ) : ChatMessageUiState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index a308def18..edc8783f0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -68,6 +68,7 @@ fun ChatScreen( animateGifs: Boolean = true, onEmoteClick: (emotes: List) -> Unit = {}, onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit = { _, _ -> }, + onWhisperReply: ((userName: UserName) -> Unit)? = null, showInput: Boolean = true, isFullscreen: Boolean = false, hasHelperText: Boolean = false, @@ -118,7 +119,7 @@ fun ChatScreen( ) { items( items = reversedMessages, - key = { message -> "${message.id}-${message.tag}" }, + key = { message -> message.id }, contentType = { message -> when (message) { is ChatMessageUiState.SystemMessageUi -> "system" @@ -140,6 +141,7 @@ fun ChatScreen( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, onReplyClick = onReplyClick, + onWhisperReply = onWhisperReply, ) // Add divider after each message if enabled @@ -240,6 +242,7 @@ private fun ChatMessageItem( onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onEmoteClick: (emotes: List) -> Unit, onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit, + onWhisperReply: ((userName: UserName) -> Unit)? = null, ) { when (message) { is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( @@ -288,7 +291,8 @@ private fun ChatMessageItem( onMessageLongClick = { messageId, fullMessage -> onMessageLongClick(messageId, null, fullMessage) }, - onEmoteClick = onEmoteClick + onEmoteClick = onEmoteClick, + onWhisperReply = onWhisperReply ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index f6e0d87fc..a4430a4bb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -13,6 +13,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.BasicText 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 @@ -33,6 +37,7 @@ import androidx.compose.ui.unit.sp import androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.EmoteDimensions @@ -57,11 +62,13 @@ fun WhisperMessageComposable( onUserClick: (userId: String?, userName: String, displayName: String, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, fullMessage: String) -> Unit, onEmoteClick: (emotes: List) -> Unit, + onWhisperReply: ((userName: UserName) -> Unit)? = null, ) { val interactionSource = remember { MutableInteractionSource() } val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) - - Box( + + Row( + verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .wrapContentHeight() @@ -70,14 +77,29 @@ fun WhisperMessageComposable( .padding(horizontal = 8.dp, vertical = 2.dp) .alpha(message.textAlpha) ) { - WhisperMessageText( - message = message, - fontSize = fontSize, - animateGifs = animateGifs, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick - ) + Box(modifier = Modifier.weight(1f)) { + WhisperMessageText( + message = message, + fontSize = fontSize, + animateGifs = animateGifs, + 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 + ) + } + } } } @@ -137,7 +159,9 @@ private fun WhisperMessageText( append(message.senderName) pop() } - append(" -> ") + withStyle(SpanStyle(color = defaultTextColor)) { + append(" -> ") + } // Recipient withStyle( @@ -148,7 +172,9 @@ private fun WhisperMessageText( ) { append(message.recipientName) } - append(": ") + withStyle(SpanStyle(color = defaultTextColor)) { + append(": ") + } // Message text with emotes withStyle(SpanStyle(color = defaultTextColor)) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index aafcf92be..2a6d643e4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.chat.mention.compose +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -7,6 +8,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.LocalPlatformContext import coil3.imageLoader +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator @@ -31,6 +33,8 @@ fun MentionComposable( 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, + contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) @@ -50,7 +54,9 @@ fun MentionComposable( modifier = modifier, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick + onEmoteClick = onEmoteClick, + onWhisperReply = if (isWhisperTab) onWhisperReply else null, + contentPadding = contentPadding ) } // CompositionLocalProvider } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index 466673f3e..fbecfaed1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.chat.replies.compose +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -32,6 +33,7 @@ fun RepliesComposable( onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onNotFound: () -> Unit, + contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) @@ -49,7 +51,8 @@ fun RepliesComposable( modifier = modifier, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, - onEmoteClick = { /* no-op for replies */ } + onEmoteClick = { /* no-op for replies */ }, + contentPadding = contentPadding ) } is RepliesUiState.NotFound -> { 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 910bdfd59..27fe79a07 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 @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import java.util.Locale +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.CoroutineContext class NotificationService : Service(), CoroutineScope { @@ -52,6 +53,7 @@ class NotificationService : Service(), CoroutineScope { private var notificationsJob: Job? = null private val notifications = mutableMapOf>() + private val notifiedMessageIds = LinkedHashSet() private val chatRepository: ChatRepository by inject() private val dataRepository: DataRepository by inject() @@ -210,12 +212,21 @@ class NotificationService : Service(), CoroutineScope { fun checkForNotification() { shouldNotifyOnMention = false + notifiedMessageIds.clear() notificationsJob?.cancel() notificationsJob = launch { chatRepository.notificationsFlow.collect { items -> items.forEach { (message) -> if (shouldNotifyOnMention && notificationsEnabled) { + if (!notifiedMessageIds.add(message.id)) { + return@forEach // Already notified for this message + } + if (notifiedMessageIds.size > MAX_NOTIFIED_IDS) { + val iterator = notifiedMessageIds.iterator() + iterator.next() + iterator.remove() + } val data = message.toNotificationData() data?.createMentionNotification() } @@ -304,7 +315,7 @@ class NotificationService : Service(), CoroutineScope { 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) + PendingIntent.getActivity(this@NotificationService, notificationIntentCode.getAndIncrement(), it, pendingIntentFlag) } val summary = NotificationCompat.Builder(this@NotificationService, CHANNEL_ID_DEFAULT) @@ -330,7 +341,7 @@ class NotificationService : Service(), CoroutineScope { .setGroup(MENTION_GROUP) .build() - val id = notificationId + val id = notificationId.getAndIncrement() notifications.getOrPut(channel) { mutableListOf() } += id manager.notify(id, notification) @@ -352,9 +363,9 @@ class NotificationService : Service(), CoroutineScope { 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 var notificationId = 42 - get() = field++ - private var notificationIntentCode = 420 - get() = field++ + private const val MAX_NOTIFIED_IDS = 500 + + private val notificationId = AtomicInteger(42) + private val notificationIntentCode = AtomicInteger(420) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 6ceb27ac4..cb132fad1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -68,8 +68,8 @@ class ChannelDataCoordinator( stateFlow.value = state } - // After channel data loaded, wait for global data too, then reparse - globalLoadJob?.join() + // Reparse immediately with whatever emotes are available now + // Don't wait for globalLoadJob — channel 3rd party emotes should show immediately chatRepository.reparseAllEmotesAndBadges() } } @@ -84,9 +84,14 @@ class ChannelDataCoordinator( val results = globalDataLoader.loadGlobalData() - // Load user state emotes if logged in + // Reparse after global emotes load so 3rd party globals are visible immediately + chatRepository.reparseAllEmotesAndBadges() + + // Load user state emotes if logged in (slow, paginated) if (preferenceStore.isLoggedIn) { loadUserStateEmotesIfAvailable() + // Reparse again after user emotes finish so they become visible + chatRepository.reparseAllEmotesAndBadges() } val failures = dataRepository.dataLoadingFailures.value diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt index 24a062ead..ccad80c94 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt @@ -3,6 +3,7 @@ package com.flxrs.dankchat.main sealed interface InputState { object Default : InputState object Replying : InputState + object Whispering : 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 index c99711096..37436bb85 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -183,6 +183,9 @@ class MainActivity : AppCompatActivity() { if (useComposeUi) { setupComposeUi() + intent.parcelable(OPEN_CHANNEL_KEY)?.let { channel -> + lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channel)) } + } } else { setupFragmentUi() } @@ -584,8 +587,12 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - val channelExtra = intent.parcelable(OPEN_CHANNEL_KEY) - channelToOpen = channelExtra + val channelExtra = intent.parcelable(OPEN_CHANNEL_KEY) ?: return + if (developerSettingsDataStore.current().useComposeChatUi) { + lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channelExtra)) } + } else { + channelToOpen = channelExtra + } } fun clearNotificationsOfChannel(channel: UserName) = when { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt index e6e13a7e6..753b7a190 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt @@ -13,4 +13,5 @@ sealed interface MainEvent { data class LoginOutdated(val username: UserName) : MainEvent data object LoginTokenInvalid : MainEvent data object LoginValidationFailed : MainEvent + data class OpenChannel(val channel: UserName) : 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 index e3354d847..a2325edae 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt @@ -415,6 +415,7 @@ class MainFragment : Fragment() { binding.inputLayout.hint = when (state) { InputState.Default -> getString(R.string.hint_connected) InputState.Replying -> getString(R.string.hint_replying) + InputState.Whispering -> getString(R.string.hint_whispering) InputState.NotLoggedIn -> getString(R.string.hint_not_logged_int) InputState.Disconnected -> getString(R.string.hint_disconnected) } @@ -463,6 +464,7 @@ class MainFragment : Fragment() { MainEvent.LogOutRequested -> showLogoutConfirmationDialog() is MainEvent.UploadSuccess, is MainEvent.UploadFailed, MainEvent.UploadLoading -> Unit is MainEvent.LoginValidated, is MainEvent.LoginOutdated, MainEvent.LoginTokenInvalid, MainEvent.LoginValidationFailed -> Unit + is MainEvent.OpenChannel -> Unit } } collectFlow(channelMentionCount, ::updateChannelMentionBadges) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt index 9660a78a0..911bdb896 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt @@ -473,6 +473,7 @@ class MainViewModel( val userId = dankChatPreferenceStore.userIdString if (userId != null) { dataRepository.loadUserEmotes(userId) + chatRepository.reparseAllEmotesAndBadges() } checkFailuresAndEmitState() @@ -672,6 +673,7 @@ class MainViewModel( val userId = dankChatPreferenceStore.userIdString if (userId != null) { dataRepository.loadUserEmotes(userId) + chatRepository.reparseAllEmotesAndBadges() } checkFailuresAndEmitState() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 64cac6d8c..98fee3e7a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Keyboard import androidx.compose.material.icons.filled.MoreVert @@ -96,13 +97,19 @@ fun ChatInputLayout( onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, onToggleStream: () -> Unit, + showWhisperOverlay: Boolean, + whisperTarget: UserName?, + onWhisperDismiss: () -> Unit, onChangeRoomState: () -> Unit, + onNewWhisper: (() -> Unit)? = null, + showQuickActions: Boolean = true, modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } val hint = when (inputState) { InputState.Default -> stringResource(R.string.hint_connected) InputState.Replying -> stringResource(R.string.hint_replying) + InputState.Whispering -> stringResource(R.string.hint_whispering) InputState.NotLoggedIn -> stringResource(R.string.hint_not_logged_int) InputState.Disconnected -> stringResource(R.string.hint_disconnected) } @@ -178,6 +185,44 @@ fun ChatInputLayout( } } + // Whisper Header + AnimatedVisibility( + visible = showWhisperOverlay && whisperTarget != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + 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 = stringResource(R.string.whisper_header, whisperTarget?.value.orEmpty()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + IconButton( + onClick = onWhisperDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + modifier = Modifier.size(16.dp) + ) + } + } + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + // Text Field TextField( state = textFieldState, @@ -252,13 +297,11 @@ fun ChatInputLayout( Icon( imageVector = Icons.Default.Keyboard, contentDescription = stringResource(R.string.dialog_dismiss), - tint = MaterialTheme.colorScheme.onSurfaceVariant ) } else { Icon( imageVector = Icons.Default.EmojiEmotions, contentDescription = stringResource(R.string.emote_menu_hint), - tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -266,15 +309,30 @@ fun ChatInputLayout( Spacer(modifier = Modifier.weight(1f)) // Quick Actions Button - IconButton( - onClick = { quickActionsExpanded = !quickActionsExpanded }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (showQuickActions) { + IconButton( + onClick = { quickActionsExpanded = !quickActionsExpanded }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // New Whisper Button (only on whisper tab) + if (onNewWhisper != null) { + IconButton( + onClick = onNewWhisper, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.whisper_new), + ) + } } // History Button (Always visible) @@ -286,7 +344,6 @@ fun ChatInputLayout( Icon( imageVector = Icons.Default.History, contentDescription = stringResource(R.string.resume_scroll), - tint = MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index ee96a74d3..a4e6c8e7c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -22,6 +22,7 @@ import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.main.RepeatedSendData import com.flxrs.dankchat.main.compose.FullScreenSheetState 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.preferences.stream.StreamsSettingsDataStore @@ -58,6 +59,7 @@ class ChatInputViewModel( private val developerSettingsDataStore: DeveloperSettingsDataStore, private val streamDataRepository: StreamDataRepository, private val streamsSettingsDataStore: StreamsSettingsDataStore, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val mainEventBus: MainEventBus, ) : ViewModel() { @@ -74,6 +76,9 @@ class ChatInputViewModel( private val _isEmoteMenuOpen = MutableStateFlow(false) val isEmoteMenuOpen = _isEmoteMenuOpen.asStateFlow() + private val _whisperTarget = MutableStateFlow(null) + val whisperTarget: StateFlow = _whisperTarget.asStateFlow() + // Create flow from TextFieldState private val textFlow = snapshotFlow { textFieldState.text.toString() } @@ -137,6 +142,19 @@ class ChatInputViewModel( } } + // 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()) { @@ -170,16 +188,18 @@ class ChatInputViewModel( val suggestions: List, val activeChannel: UserName?, val connectionState: ConnectionState, - val isLoggedIn: Boolean + val isLoggedIn: Boolean, + val autoDisableInput: Boolean ) - private data class SheetAndReplyState( + private data class InputOverlayState( val sheetState: FullScreenSheetState, val tab: Int, val isReplying: Boolean, val replyName: UserName?, val replyMessageId: String?, - val isEmoteMenuOpen: Boolean + val isEmoteMenuOpen: Boolean, + val whisperTarget: UserName? ) fun uiState(fullScreenSheetState: StateFlow, mentionSheetTab: StateFlow): StateFlow { @@ -193,9 +213,9 @@ class ChatInputViewModel( if (channel == null) flowOf(ConnectionState.DISCONNECTED) else chatRepository.getConnectionState(channel) }, - preferenceStore.isLoggedInFlow - ) { text, suggestions, activeChannel, connectionState, isLoggedIn -> - UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn) + combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b } + ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, autoDisableInput) -> + UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput) } val replyStateFlow = combine( @@ -206,30 +226,34 @@ class ChatInputViewModel( Triple(isReplying, replyName, replyMessageId) } - val sheetAndReplyFlow = combine( + val inputOverlayFlow = combine( fullScreenSheetState, mentionSheetTab, replyStateFlow, - _isEmoteMenuOpen - ) { sheetState, tab, replyState, isEmoteMenuOpen -> + _isEmoteMenuOpen, + _whisperTarget + ) { sheetState, tab, replyState, isEmoteMenuOpen, whisperTarget -> val (isReplying, replyName, replyMessageId) = replyState - SheetAndReplyState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen) + InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget) } _uiState = combine( baseFlow, - sheetAndReplyFlow, + inputOverlayFlow, helperText - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen), helperText -> + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget), helperText -> this.fullScreenSheetState.value = sheetState this.mentionSheetTab.value = tab val isMentionsTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 0 + val isWhisperTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 1 val isInReplyThread = sheetState is FullScreenSheetState.Replies val effectiveIsReplying = isReplying || isInReplyThread + val canTypeInConnectionState = connectionState == ConnectionState.CONNECTED || !autoDisableInput val inputState = when (connectionState) { ConnectionState.CONNECTED -> when { + isWhisperTabActive && whisperTarget != null -> InputState.Whispering effectiveIsReplying -> InputState.Replying else -> InputState.Default } @@ -237,10 +261,16 @@ class ChatInputViewModel( ConnectionState.DISCONNECTED -> InputState.Disconnected } - val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn && !isMentionsTabActive - val enabled = isLoggedIn && connectionState == ConnectionState.CONNECTED && !isMentionsTabActive + val enabled = when { + isMentionsTabActive -> false + isWhisperTabActive -> isLoggedIn && canTypeInConnectionState && whisperTarget != null + else -> isLoggedIn && canTypeInConnectionState + } + + val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn && enabled val showReplyOverlay = isReplying && !isInReplyThread + val showWhisperOverlay = isWhisperTabActive && whisperTarget != null val effectiveReplyName = replyName ?: (sheetState as? FullScreenSheetState.Replies)?.replyName ChatInputUiState( @@ -256,7 +286,10 @@ class ChatInputViewModel( replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, replyName = effectiveReplyName, isEmoteMenuOpen = isEmoteMenuOpen, - helperText = helperText + helperText = helperText, + showWhisperOverlay = showWhisperOverlay, + whisperTarget = whisperTarget, + isWhisperTabActive = isWhisperTabActive ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) @@ -266,7 +299,13 @@ class ChatInputViewModel( fun sendMessage() { val text = textFieldState.text.toString() if (text.isNotBlank()) { - trySendMessageOrCommand(text) + val whisperTarget = _whisperTarget.value + val messageToSend = if (whisperTarget != null) { + "/w ${whisperTarget.value} $text" + } else { + text + } + trySendMessageOrCommand(messageToSend) textFieldState.clearText() } } @@ -347,6 +386,13 @@ class ChatInputViewModel( _replyName.value = replyName } + fun setWhisperTarget(target: UserName?) { + _whisperTarget.value = target + if (target == null) { + textFieldState.clearText() + } + } + fun insertText(text: String) { textFieldState.edit { append(text) @@ -415,5 +461,8 @@ data class ChatInputUiState( val replyMessageId: String? = null, val replyName: UserName? = null, val isEmoteMenuOpen: Boolean = false, - val helperText: String? = null + val helperText: String? = null, + val showWhisperOverlay: Boolean = false, + val whisperTarget: UserName? = null, + val isWhisperTabActive: Boolean = false ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index a2a065a8b..43ff4d0d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -13,7 +13,6 @@ 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.interaction.MutableInteractionSource @@ -51,11 +50,12 @@ 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.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.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -71,7 +71,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.ui.layout.layout import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -113,14 +112,13 @@ fun FloatingToolbar( if (tabState.tabs.isEmpty()) return val density = LocalDensity.current - val scope = rememberCoroutineScope() var isTabsExpanded by remember { mutableStateOf(false) } var showOverflowMenu by remember { mutableStateOf(false) } var overflowInitialMenu by remember { mutableStateOf(AppBarMenu.Main) } val totalTabs = tabState.tabs.size val hasOverflow = totalTabs > 3 - val selectedIndex = tabState.selectedIndex + val selectedIndex = composePagerState.currentPage val tabListState = rememberLazyListState() // Expand tabs when pager is swiped in a direction with more channels @@ -155,6 +153,7 @@ fun FloatingToolbar( } } + // Dismiss scrim for inline overflow menu if (showOverflowMenu) { Box( @@ -179,9 +178,25 @@ fun FloatingToolbar( .padding(top = if (currentStream != null && streamHeightDp > 0.dp) streamHeightDp else with(density) { WindowInsets.statusBars.getTop(density).toDp() }) .padding(top = 8.dp) ) { - // Auto-scroll to keep selected tab visible - LaunchedEffect(selectedIndex) { - tabListState.animateScrollToItem(selectedIndex) + // Auto-scroll whenever the selected tab isn't fully visible + LaunchedEffect(Unit) { + snapshotFlow { + val currentIndex = composePagerState.currentPage + val layoutInfo = tabListState.layoutInfo + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + val itemInfo = layoutInfo.visibleItemsInfo.find { it.index == currentIndex } + when { + itemInfo == null -> currentIndex + itemInfo.offset < viewportStart -> currentIndex + itemInfo.offset + itemInfo.size > viewportEnd -> currentIndex + else -> -1 + } + }.collect { targetIndex -> + if (targetIndex >= 0) { + tabListState.animateScrollToItem(targetIndex) + } + } } // Mention indicators based on visibility @@ -205,54 +220,56 @@ fun FloatingToolbar( exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), ) { Box(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 mentionGradientColor = MaterialTheme.colorScheme.error LazyRow( state = tabListState, - contentPadding = PaddingValues(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(horizontal = 8.dp) - .wrapLazyRowContent(tabListState) - .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) - ) - } - } + .wrapLazyRowContent(tabListState, extraWidth = with(density) { 24.dp.roundToPx() }) + .padding(horizontal = 12.dp) + .clipToBounds() ) { itemsIndexed( items = tabState.tabs, key = { _, tab -> tab.channel.value } ) { index, tab -> - val isSelected = tab.isSelected + val isSelected = index == selectedIndex val textColor = when { isSelected -> MaterialTheme.colorScheme.primary tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface @@ -398,12 +415,12 @@ fun FloatingToolbar( } /** Measures [LazyRow] at full width (for scrolling) but reports actual content width so the pill wraps content. */ -private fun Modifier.wrapLazyRowContent(listState: LazyListState) = layout { measurable, constraints -> +private fun Modifier.wrapLazyRowContent(listState: LazyListState, extraWidth: Int = 0) = layout { measurable, constraints -> val placeable = measurable.measure(constraints) val items = listState.layoutInfo.visibleItemsInfo val contentWidth = if (items.isNotEmpty()) { val lastItem = items.last() - lastItem.offset + lastItem.size + listState.layoutInfo.afterContentPadding + lastItem.offset + lastItem.size + extraWidth } else { placeable.width } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index b099e09b4..27bbd230b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams @@ -32,6 +34,8 @@ fun FullScreenSheetOverlay( onUserClick: (UserPopupStateParams) -> Unit, onMessageLongClick: (MessageOptionsParams) -> Unit, onEmoteClick: (List) -> Unit, + onWhisperReply: (UserName) -> Unit = {}, + bottomContentPadding: Dp = 0.dp, modifier: Modifier = Modifier, ) { AnimatedVisibility( @@ -76,7 +80,9 @@ fun FullScreenSheetOverlay( ) ) }, - onEmoteClick = onEmoteClick + onEmoteClick = onEmoteClick, + onWhisperReply = onWhisperReply, + bottomContentPadding = bottomContentPadding, ) } is FullScreenSheetState.Whisper -> { @@ -98,7 +104,9 @@ fun FullScreenSheetOverlay( ) ) }, - onEmoteClick = onEmoteClick + onEmoteClick = onEmoteClick, + onWhisperReply = onWhisperReply, + bottomContentPadding = bottomContentPadding, ) } is FullScreenSheetState.Replies -> { @@ -118,7 +126,8 @@ fun FullScreenSheetOverlay( canCopy = true ) ) - } + }, + bottomContentPadding = bottomContentPadding, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index ea7198dc0..087f551e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface @@ -91,6 +92,7 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.main.MainActivity import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.main.compose.sheets.EmoteMenu @@ -104,6 +106,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.ui.platform.LocalResources import androidx.window.core.layout.WindowSizeClass import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -127,6 +130,7 @@ fun MainScreen( onChooseMedia: () -> Unit, modifier: Modifier = Modifier ) { + val resources = LocalResources.current val context = LocalContext.current val density = LocalDensity.current // Scoped ViewModels - each handles one concern @@ -269,11 +273,13 @@ fun MainScreen( var isUploading by remember { mutableStateOf(false) } var showLoginOutdatedDialog by remember { mutableStateOf(null) } var showLoginExpiredDialog by remember { mutableStateOf(false) } + var showNewWhisperDialog by remember { mutableStateOf(false) } val toolsSettingsDataStore: ToolsSettingsDataStore = koinInject() val userStateRepository: UserStateRepository = koinInject() val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() + val isSheetOpen = fullScreenSheetState !is FullScreenSheetState.Closed val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -284,8 +290,8 @@ fun MainScreen( is MainEvent.UploadSuccess -> { isUploading = false val result = snackbarHostState.showSnackbar( - message = context.getString(R.string.snackbar_image_uploaded, event.url), - actionLabel = context.getString(R.string.snackbar_paste), + message = resources.getString(R.string.snackbar_image_uploaded, event.url), + actionLabel = resources.getString(R.string.snackbar_paste), duration = SnackbarDuration.Long ) if (result == SnackbarResult.ActionPerformed) { @@ -294,13 +300,13 @@ fun MainScreen( } is MainEvent.UploadFailed -> { isUploading = false - val message = event.errorMessage?.let { context.getString(R.string.snackbar_upload_failed_cause, it) } - ?: context.getString(R.string.snackbar_upload_failed) + 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.Long) } is MainEvent.LoginValidated -> { snackbarHostState.showSnackbar( - message = context.getString(R.string.snackbar_login, event.username), + message = resources.getString(R.string.snackbar_login, event.username), duration = SnackbarDuration.Short ) } @@ -312,10 +318,16 @@ fun MainScreen( } MainEvent.LoginValidationFailed -> { snackbarHostState.showSnackbar( - message = context.getString(R.string.oauth_verify_failed), + message = resources.getString(R.string.oauth_verify_failed), duration = SnackbarDuration.Short ) } + is MainEvent.OpenChannel -> { + channelTabViewModel.selectTab( + preferenceStore.channels.indexOf(event.channel) + ) + (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) + } else -> Unit } } @@ -332,9 +344,9 @@ fun MainScreen( scope.launch { val name = preferenceStore.userName val message = if (name != null) { - context.getString(R.string.snackbar_login, name) + resources.getString(R.string.snackbar_login, name) } else { - context.getString(R.string.login) // Fallback + resources.getString(R.string.login) // Fallback } snackbarHostState.showSnackbar(message) } @@ -349,7 +361,7 @@ fun MainScreen( scope.launch { snackbarHostState.showSnackbar( message = state.message, - actionLabel = context.getString(R.string.snackbar_retry), + actionLabel = resources.getString(R.string.snackbar_retry), duration = SnackbarDuration.Long ) } @@ -436,12 +448,47 @@ fun MainScreen( ) } + // New Whisper dialog + if (showNewWhisperDialog) { + var whisperUsername by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { showNewWhisperDialog = false }, + title = { Text(stringResource(R.string.whisper_new_dialog_title)) }, + text = { + OutlinedTextField( + value = whisperUsername, + onValueChange = { whisperUsername = it }, + label = { Text(stringResource(R.string.whisper_new_dialog_hint)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { + val username = whisperUsername.trim() + if (username.isNotBlank()) { + chatInputViewModel.setWhisperTarget(UserName(username)) + showNewWhisperDialog = false + } + } + ) { + Text(stringResource(R.string.whisper_new_dialog_start)) + } + }, + dismissButton = { + TextButton(onClick = { showNewWhisperDialog = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() // Hide/show system bars when fullscreen toggles - val window = (LocalContext.current as? Activity)?.window + val window = (context as? Activity)?.window val view = LocalView.current DisposableEffect(isFullscreen, window, view) { if (window == null) return@DisposableEffect onDispose { } @@ -467,10 +514,7 @@ fun MainScreen( pageCount = { pagerState.channels.size } ) var inputHeightPx by remember { mutableIntStateOf(0) } - var inputTopY by remember { mutableStateOf(0f) } - var containerHeight by remember { mutableIntStateOf(0) } val inputHeightDp = with(density) { inputHeightPx.toDp() } - val sheetBottomPadding = with(density) { (containerHeight - inputTopY).toDp() } // Clear focus when keyboard fully reaches the bottom, but not when // switching to the emote menu. Prevents keyboard from reopening when @@ -487,10 +531,10 @@ fun MainScreen( } // Sync Compose pager with ViewModel state - LaunchedEffect(pagerState.currentPage) { + LaunchedEffect(pagerState.currentPage, pagerState.channels.size) { if (!composePagerState.isScrollInProgress && composePagerState.currentPage != pagerState.currentPage && - pagerState.currentPage < pagerState.channels.size + pagerState.currentPage in 0 until composePagerState.pageCount ) { composePagerState.scrollToPage(pagerState.currentPage) } @@ -507,7 +551,6 @@ fun MainScreen( Box(modifier = Modifier .fillMaxSize() .then(if (!isFullscreen && !isInPipMode) Modifier.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) else Modifier) - .onGloballyPositioned { containerHeight = it.size.height } ) { // Menu content height matches keyboard content area (above nav bar) val targetMenuHeight = if (keyboardHeightPx > 0) { @@ -523,7 +566,7 @@ fun MainScreen( val totalMenuHeight = targetMenuHeight + navBarHeightDp // Shared scaffold bottom padding calculation - val hasDialogWithInput = showAddChannelDialog || showRoomStateDialog || showManageChannelsDialog + val hasDialogWithInput = showAddChannelDialog || showRoomStateDialog || showManageChannelsDialog || showNewWhisperDialog 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) @@ -556,6 +599,11 @@ fun MainScreen( keyboardController?.show() } }, + showWhisperOverlay = inputState.showWhisperOverlay, + whisperTarget = inputState.whisperTarget, + onWhisperDismiss = { + chatInputViewModel.setWhisperTarget(null) + }, onReplyDismiss = { chatInputViewModel.setReplying(false) }, @@ -565,9 +613,10 @@ fun MainScreen( activeChannel?.let { streamViewModel.toggleStream(it) } }, onChangeRoomState = { showRoomStateDialog = true }, + onNewWhisper = if (inputState.isWhisperTabActive) {{ showNewWhisperDialog = true }} else null, + showQuickActions = !isSheetOpen, modifier = Modifier.onGloballyPositioned { coordinates -> inputHeightPx = coordinates.size.height - inputTopY = coordinates.positionInRoot().y } ) } @@ -765,6 +814,24 @@ fun MainScreen( } } } + + // Fullscreen Overlay Sheets - inside Scaffold content for edge-to-edge + FullScreenSheetOverlay( + sheetState = fullScreenSheetState, + isLoggedIn = isLoggedIn, + mentionViewModel = mentionViewModel, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onDismissReplies = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = { userPopupParams = it }, + onMessageLongClick = { messageOptionsParams = it }, + onEmoteClick = { emoteInfoEmotes = it }, + onWhisperReply = chatInputViewModel::setWhisperTarget, + bottomContentPadding = paddingValues.calculateBottomPadding(), + ) } } @@ -821,29 +888,13 @@ fun MainScreen( floatingToolbar( Modifier.align(Alignment.TopCenter), - !isKeyboardVisible && !inputState.isEmoteMenuOpen, + !isKeyboardVisible && !inputState.isEmoteMenuOpen && !isSheetOpen, false, showTabsInSplit, ) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - FullScreenSheetOverlay( - sheetState = fullScreenSheetState, - isLoggedIn = isLoggedIn, - mentionViewModel = mentionViewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onDismissReplies = { - sheetNavigationViewModel.closeFullScreenSheet() - chatInputViewModel.setReplying(false) - }, - onUserClick = { userPopupParams = it }, - onMessageLongClick = { messageOptionsParams = it }, - onEmoteClick = { emoteInfoEmotes = it }, - modifier = Modifier.padding(bottom = sheetBottomPadding), - ) - if (showInputState && isKeyboardVisible) { SuggestionDropdown( suggestions = inputState.suggestions, @@ -923,7 +974,7 @@ fun MainScreen( // Floating Toolbars - collapsible tabs (expand on swipe) + actions if (!isInPipMode) floatingToolbar( Modifier.align(Alignment.TopCenter), - !isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen), + (!isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen)) && !isSheetOpen, true, true, ) @@ -932,23 +983,6 @@ fun MainScreen( // Fast tween to match system keyboard animation speed if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - // Fullscreen Overlay Sheets - if (!isInPipMode) FullScreenSheetOverlay( - sheetState = fullScreenSheetState, - isLoggedIn = isLoggedIn, - mentionViewModel = mentionViewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onDismissReplies = { - sheetNavigationViewModel.closeFullScreenSheet() - chatInputViewModel.setReplying(false) - }, - onUserClick = { userPopupParams = it }, - onMessageLongClick = { messageOptionsParams = it }, - onEmoteClick = { emoteInfoEmotes = it }, - modifier = Modifier.padding(bottom = sheetBottomPadding), - ) - if (!isInPipMode && showInputState && isKeyboardVisible) { SuggestionDropdown( suggestions = inputState.suggestions, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 6afd22266..22ce47184 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -1,23 +1,28 @@ package com.flxrs.dankchat.main.compose.sheets -import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.foundation.layout.Column +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.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.padding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.statusBars 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.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface 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 @@ -25,20 +30,23 @@ 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.graphics.graphicsLayer +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.chat.compose.BadgeUi import com.flxrs.dankchat.chat.mention.compose.MentionComposable import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MentionSheet( mentionViewModel: MentionComposeViewModel, @@ -48,14 +56,21 @@ fun MentionSheet( 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) } + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + // Toolbar area: status bar + padding + pill height + padding + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 8.dp + LaunchedEffect(pagerState.currentPage) { mentionViewModel.setCurrentTab(pagerState.currentPage) } @@ -71,47 +86,22 @@ fun MentionSheet( } } - Scaffold( + Box( modifier = Modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .graphicsLayer { val scale = 1f - (backProgress * 0.1f) scaleX = scale scaleY = scale alpha = 1f - backProgress translationY = backProgress * 100f - }, - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - Column { - TopAppBar( - title = { Text(stringResource(R.string.mentions_title)) }, - navigationIcon = { - IconButton(onClick = onDismiss) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - } - ) - PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(stringResource(R.string.mentions)) } - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(stringResource(R.string.whispers)) } - ) - } } - }, - ) { paddingValues -> + ) { + // Chat content - edge to edge HorizontalPager( state = pagerState, - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { page -> MentionComposable( mentionViewModel = mentionViewModel, @@ -119,10 +109,72 @@ fun MentionSheet( isWhisperTab = page == 1, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick + onEmoteClick = onEmoteClick, + onWhisperReply = if (page == 1) onWhisperReply else null, + contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), ) } - } + // Status bar scrim + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) + ) -} \ No newline at end of file + // Floating toolbar: back pill + tab pill + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = statusBarHeight + 8.dp) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + // Back navigation pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + + // Tab pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + 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, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index 0d7d4b7ee..0373ec86f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -1,25 +1,35 @@ package com.flxrs.dankchat.main.compose.sheets import androidx.activity.compose.PredictiveBackHandler +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.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.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.chat.compose.BadgeUi import com.flxrs.dankchat.chat.replies.compose.RepliesComposable @@ -29,7 +39,6 @@ import kotlinx.coroutines.CancellationException import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RepliesSheet( rootMessageId: String, @@ -37,13 +46,18 @@ fun RepliesSheet( onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + bottomContentPadding: Dp = 0.dp, ) { val viewModel: RepliesComposeViewModel = koinViewModel( key = rootMessageId, parameters = { parametersOf(rootMessageId) } ) + val density = LocalDensity.current var backProgress by remember { mutableFloatStateOf(0f) } + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 8.dp + PredictiveBackHandler { progress -> try { progress.collect { event -> @@ -55,20 +69,10 @@ fun RepliesSheet( } } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.replies_title)) }, - navigationIcon = { - IconButton(onClick = onDismiss) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - } - ) - }, - contentWindowInsets = WindowInsets(0, 0, 0, 0), + Box( modifier = Modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .graphicsLayer { val scale = 1f - (backProgress * 0.1f) scaleX = scale @@ -76,16 +80,61 @@ fun RepliesSheet( alpha = 1f - backProgress translationY = backProgress * 100f } - ) { paddingValues -> + ) { + // Chat content - edge to edge RepliesComposable( repliesViewModel = viewModel, appearanceSettingsDataStore = appearanceSettingsDataStore, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onNotFound = onDismiss, - modifier = Modifier.padding(paddingValues).fillMaxSize(), + contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), + modifier = Modifier.fillMaxSize(), + ) + + // Status bar scrim + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) ) - } + // Floating toolbar: back pill + title pill + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = statusBarHeight + 8.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.Top + ) { + // Back navigation pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + // Title pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.padding(start = 8.dp) + ) { + 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index d68c7e792..a9842af70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -429,6 +429,12 @@ Copy message id More… 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 From 77e1252a35492b60cc78833a9c2d921f7a8317ec Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 022/349] fix(compose): Connection, toolbar, and polish --- .../com/flxrs/dankchat/DankChatViewModel.kt | 26 ++++--- .../chat/compose/AdaptiveTextColor.kt | 17 ++++ .../chat/compose/ChatMessageMapper.kt | 27 ++----- .../chat/compose/ChatMessageUiState.kt | 9 +-- .../chat/compose/messages/PrivMessage.kt | 3 +- .../compose/messages/WhisperAndRedemption.kt | 5 +- .../com/flxrs/dankchat/main/MainActivity.kt | 6 +- .../dankchat/main/compose/FloatingToolbar.kt | 50 ++++++++++-- .../flxrs/dankchat/main/compose/MainScreen.kt | 10 ++- .../flxrs/dankchat/main/compose/StreamView.kt | 5 +- .../main/compose/sheets/MentionSheet.kt | 26 ++++--- .../main/compose/sheets/RepliesSheet.kt | 26 ++++--- .../utils/extensions/ColorExtensions.kt | 77 +++++++++++++------ 13 files changed, 185 insertions(+), 102 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index aac01f354..99ea33fa8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -40,7 +40,7 @@ class DankChatViewModel( ) : ViewModel() { val serviceEvents = dataRepository.serviceEvents - private var started = false + private var initialConnectionStarted = false val activeChannel = chatRepository.activeChannel val isLoggedIn = dankChatPreferenceStore.isLoggedInFlow @@ -57,20 +57,22 @@ class DankChatViewModel( initialValue = appearanceSettingsDataStore.current().keepScreenOn, ) - fun init(tryReconnect: Boolean) { + init { viewModelScope.launch { - if (tryReconnect && started) { - chatRepository.reconnectIfNecessary() - dataRepository.reconnectIfNecessary() - } else { - started = true + if (dankChatPreferenceStore.isLoggedIn) { + validateUser() + } + initialConnectionStarted = true + chatRepository.connectAndJoin() + } + } - if (dankChatPreferenceStore.isLoggedIn) { - validateUser() - } + fun reconnectIfNecessary() { + if (!initialConnectionStarted) return - chatRepository.connectAndJoin() - } + viewModelScope.launch { + chatRepository.reconnectIfNecessary() + dataRepository.reconnectIfNecessary() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt index 7b67d9372..56502e904 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt @@ -2,9 +2,11 @@ package com.flxrs.dankchat.chat.compose 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.toArgb import com.flxrs.dankchat.theme.LocalAdaptiveColors +import com.flxrs.dankchat.utils.extensions.normalizeColor import com.google.android.material.color.MaterialColors /** @@ -35,3 +37,18 @@ fun rememberAdaptiveTextColor(backgroundColor: Color): Color { adaptiveColors.onSurfaceDark } } + +/** + * Normalizes a raw color int for readable contrast against the effective background. + * Uses [MaterialTheme.colorScheme.surface] when the background is transparent. + */ +@Composable +fun rememberNormalizedColor(rawColor: Int, backgroundColor: Color): Color { + val surfaceColor = MaterialTheme.colorScheme.surface + val effectiveBg = if (backgroundColor == Color.Transparent) surfaceColor else backgroundColor + val effectiveBgArgb = effectiveBg.toArgb() + + return remember(rawColor, effectiveBgArgb) { + Color(rawColor.normalizeColor(effectiveBgArgb)) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index ac573b43a..5ffa9e485 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -1,9 +1,7 @@ package com.flxrs.dankchat.chat.compose import android.content.Context -import android.util.Log import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.ChatImportance import com.flxrs.dankchat.chat.ChatItem @@ -20,11 +18,8 @@ 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.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.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettings import com.flxrs.dankchat.preferences.chat.ChatSettings @@ -348,9 +343,8 @@ object ChatMessageMapper { append(message) } - // Compute name colors for both light and dark backgrounds - val lightNameColorInt = customOrUserColorOn(backgroundColors.light.toArgb()) - val darkNameColorInt = customOrUserColorOn(backgroundColors.dark.toArgb()) + // Store raw color for normalization at render time (needs Compose theme context) + val rawNameColor = userDisplay?.color ?: color return ChatMessageUiState.PrivMessageUi( id = id, @@ -365,8 +359,7 @@ object ChatMessageMapper { userName = name, displayName = displayName, badges = badgeUis, - lightNameColor = Color(lightNameColorInt), - darkNameColor = Color(darkNameColorInt), + rawNameColor = rawNameColor, nameText = nameText, message = message, emotes = emoteUis, @@ -460,11 +453,9 @@ object ChatMessageMapper { append(message) } - // Compute colors for both light and dark backgrounds - val lightSenderColorInt = senderColorOnBackground(backgroundColors.light.toArgb()) - val darkSenderColorInt = senderColorOnBackground(backgroundColors.dark.toArgb()) - val lightRecipientColorInt = recipientColorOnBackground(backgroundColors.light.toArgb()) - val darkRecipientColorInt = recipientColorOnBackground(backgroundColors.dark.toArgb()) + // Store raw colors for normalization at render time (needs Compose theme context) + val rawSenderColor = userDisplay?.color ?: color + val rawRecipientColor = recipientDisplay?.color ?: recipientColor return ChatMessageUiState.WhisperMessageUi( id = id, @@ -478,10 +469,8 @@ object ChatMessageMapper { userName = name, displayName = displayName, badges = badgeUis, - lightSenderColor = Color(lightSenderColorInt), - darkSenderColor = Color(darkSenderColorInt), - lightRecipientColor = Color(lightRecipientColorInt), - darkRecipientColor = Color(darkRecipientColorInt), + rawSenderColor = rawSenderColor, + rawRecipientColor = rawRecipientColor, senderName = senderAliasOrFormattedName, recipientName = recipientAliasOrFormattedName, message = message, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 5877056b2..d61312570 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -40,8 +40,7 @@ sealed interface ChatMessageUiState { val userName: UserName, val displayName: DisplayName, val badges: List, - val lightNameColor: Color, - val darkNameColor: Color, + val rawNameColor: Int, val nameText: String, val message: String, val emotes: List, @@ -146,10 +145,8 @@ sealed interface ChatMessageUiState { val userName: UserName, val displayName: DisplayName, val badges: List, - val lightSenderColor: Color, - val darkSenderColor: Color, - val lightRecipientColor: Color, - val darkRecipientColor: Color, + val rawSenderColor: Int, + val rawRecipientColor: Int, val senderName: String, val recipientName: String, val message: String, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 109121c1b..bb5e74abe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -48,6 +48,7 @@ import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.chat.compose.rememberNormalizedColor import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @@ -137,7 +138,7 @@ private fun PrivMessageText( val emoteCoordinator = LocalEmoteAnimationCoordinator.current val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) - val nameColor = rememberBackgroundColor(message.lightNameColor, message.darkNameColor) + val nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) val linkColor = MaterialTheme.colorScheme.primary // Build annotated string with text content diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index a4430a4bb..37840a4a9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -48,6 +48,7 @@ import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.chat.compose.rememberNormalizedColor import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** @@ -116,8 +117,8 @@ private fun WhisperMessageText( val emoteCoordinator = LocalEmoteAnimationCoordinator.current val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) - val senderColor = rememberBackgroundColor(message.lightSenderColor, message.darkSenderColor) - val recipientColor = rememberBackgroundColor(message.lightRecipientColor, message.darkRecipientColor) + val senderColor = rememberNormalizedColor(message.rawSenderColor, backgroundColor) + val recipientColor = rememberNormalizedColor(message.rawRecipientColor, backgroundColor) val linkColor = MaterialTheme.colorScheme.primary // Build annotated string with text content diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 37436bb85..85be559ad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -538,6 +538,11 @@ class MainActivity : AppCompatActivity() { @SuppressLint("InlinedApi") override fun onStart() { super.onStart() + + if (!isChangingConfigurations) { + viewModel.reconnectIfNecessary() + } + val needsNotificationPermission = isAtLeastTiramisu && hasPermission(Manifest.permission.POST_NOTIFICATIONS) when { needsNotificationPermission -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) @@ -710,7 +715,6 @@ class MainActivity : AppCompatActivity() { pendingChannelsToClear.clear() } - viewModel.init(tryReconnect = !isChangingConfigurations) binder.service.checkForNotification() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index 43ff4d0d9..bc1dd10b3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -56,9 +56,11 @@ 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.foundation.background import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -69,6 +71,7 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.R import androidx.compose.foundation.lazy.LazyListState import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -137,9 +140,9 @@ fun FloatingToolbar( } } - // Auto-collapse after scroll stops + 2s delay - LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress) { - if (isTabsExpanded && !composePagerState.isScrollInProgress) { + // Auto-collapse after all scrolling stops + 2s delay + LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress, tabListState.isScrollInProgress) { + if (isTabsExpanded && !composePagerState.isScrollInProgress && !tabListState.isScrollInProgress) { delay(2000) isTabsExpanded = false } @@ -175,9 +178,35 @@ fun FloatingToolbar( exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), modifier = modifier .fillMaxWidth() - .padding(top = if (currentStream != null && streamHeightDp > 0.dp) streamHeightDp else with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .padding(top = 8.dp) + .padding(top = if (currentStream != null && streamHeightDp > 0.dp) streamHeightDp + 8.dp else 0.dp) ) { + val hasStream = currentStream != null && streamHeightDp > 0.dp + val scrimColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + val statusBarPx = with(density) { WindowInsets.statusBars.getTop(density).toFloat() } + var toolbarRowHeight by remember { mutableStateOf(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) + } + + Box(modifier = scrimModifier) { // Auto-scroll whenever the selected tab isn't fully visible LaunchedEffect(Unit) { snapshotFlow { @@ -209,7 +238,11 @@ fun FloatingToolbar( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), + .padding(horizontal = 8.dp) + .onSizeChanged { + val h = it.height.toFloat() + if (toolbarRowHeight == 0f || h < toolbarRowHeight) toolbarRowHeight = h + }, verticalAlignment = Alignment.Top ) { // Scrollable tabs pill @@ -334,7 +367,7 @@ fun FloatingToolbar( bottomStart = pillCornerRadius, bottomEnd = pillCornerRadius ), - color = MaterialTheme.colorScheme.surfaceContainer + color = MaterialTheme.colorScheme.surfaceContainer, ) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = onAddChannel) { @@ -377,7 +410,7 @@ fun FloatingToolbar( bottomStart = 12.dp, bottomEnd = 12.dp ), - color = MaterialTheme.colorScheme.surfaceContainer + color = MaterialTheme.colorScheme.surfaceContainer, ) { InlineOverflowMenu( isLoggedIn = isLoggedIn, @@ -411,6 +444,7 @@ fun FloatingToolbar( } } } + } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 087f551e0..b31310f21 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -518,10 +518,12 @@ fun MainScreen( // Clear focus when keyboard fully reaches the bottom, but not when // switching to the emote menu. Prevents keyboard from reopening when - // returning from background. + // returning from background. Debounced to avoid premature focus loss + // during heavy recomposition (e.g. emote loading/reparsing). val focusManager = LocalFocusManager.current LaunchedEffect(Unit) { snapshotFlow { imeHeightState.value == 0 && !inputState.isEmoteMenuOpen } + .debounce(150) .distinctUntilChanged() .collect { shouldClearFocus -> if (shouldClearFocus) { @@ -960,14 +962,14 @@ fun MainScreen( ) } - // Status bar scrim (hidden in fullscreen and PiP) - if (!isFullscreen && !isInPipMode) { + // Status bar scrim when stream is active (hidden in fullscreen and PiP) + if (currentStream != null && !isFullscreen && !isInPipMode) { Box( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .background(if (currentStream != null) Color.Black else MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) + .background(MaterialTheme.colorScheme.surface) ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt index e92300adc..8498ab264 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -26,7 +26,6 @@ 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -79,7 +78,7 @@ fun StreamView( modifier = modifier .then(if (isInPipMode || fillPane) Modifier else Modifier.statusBarsPadding()) .fillMaxWidth() - .background(Color.Black) + .background(MaterialTheme.colorScheme.surface) ) { val webViewModifier = when { isInPipMode || fillPane -> Modifier.fillMaxSize() @@ -121,7 +120,7 @@ fun StreamView( .padding(8.dp) .size(36.dp) .background( - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), shape = CircleShape ) ) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 22ce47184..5bc0b08dd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -32,6 +31,7 @@ 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.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -69,7 +69,7 @@ fun MentionSheet( val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } // Toolbar area: status bar + padding + pill height + padding - val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 8.dp + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp LaunchedEffect(pagerState.currentPage) { mentionViewModel.setCurrentTab(pagerState.currentPage) @@ -115,21 +115,24 @@ fun MentionSheet( ) } - // Status bar scrim + // Floating toolbar with gradient scrim Box( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .height(statusBarHeight) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) - ) - - // Floating toolbar: back pill + tab pill - Row( - modifier = Modifier - .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + 0.75f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + 1f to MaterialTheme.colorScheme.surface.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 ) { @@ -176,5 +179,6 @@ fun MentionSheet( } } } + } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index 0373ec86f..8f983b9d8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -8,7 +8,6 @@ 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 @@ -25,6 +24,7 @@ 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.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -56,7 +56,7 @@ fun RepliesSheet( var backProgress by remember { mutableFloatStateOf(0f) } val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 8.dp + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp PredictiveBackHandler { progress -> try { @@ -92,21 +92,24 @@ fun RepliesSheet( modifier = Modifier.fillMaxSize(), ) - // Status bar scrim + // Floating toolbar with gradient scrim Box( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .height(statusBarHeight) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) - ) - - // Floating toolbar: back pill + title pill - Row( - modifier = Modifier - .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + 0.75f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0f) + ) + ) .padding(top = statusBarHeight + 8.dp) + .padding(bottom = 16.dp) .padding(horizontal = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top ) { // Back navigation pill @@ -136,5 +139,6 @@ fun RepliesSheet( ) } } + } } } 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..2913441b9 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,73 @@ 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]. + * + * Uses WCAG contrast ratio (target 4.5:1 for normal text) and shifts + * the color's lightness in HSL space until the target is met, + * preserving hue and saturation as much as possible. + */ @ColorInt fun Int.normalizeColor(@ColorInt background: Int): Int { + // calculateContrast requires opaque colors; force full alpha on both + val opaqueColor = this or 0xFF000000.toInt() + val opaqueBackground = background or 0xFF000000.toInt() + val contrast = ColorUtils.calculateContrast(opaqueColor, opaqueBackground) + if (contrast >= MIN_CONTRAST_RATIO) return this - val isLightBackground = MaterialColors.isColorLight(background) val hsl = FloatArray(3) - ColorUtils.colorToHSL(this, hsl) - val huePercentage = hsl[0] / 360f + ColorUtils.colorToHSL(opaqueColor, hsl) - return when { - isLightBackground -> { - if (hsl[2] > 0.5f) { - hsl[2] = 0.5f - } + val bgLuminance = ColorUtils.calculateLuminance(opaqueBackground) + // On dark backgrounds, increase lightness; on light backgrounds, decrease it + val shouldLighten = bgLuminance < 0.5 - 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) - } - - ColorUtils.HSLToColor(hsl) - } + // 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 + } - else -> { - if (hsl[2] < 0.5f) { - hsl[2] = 0.5f - } + var bestL = hsl[2] + var bestContrast = contrast - 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) - } + repeat(MAX_ITERATIONS) { + val mid = (low + high) / 2f + hsl[2] = mid + val candidate = ColorUtils.HSLToColor(hsl) + val candidateContrast = ColorUtils.calculateContrast(candidate, opaqueBackground) - ColorUtils.HSLToColor(hsl) + if (candidateContrast >= MIN_CONTRAST_RATIO) { + bestL = mid + bestContrast = candidateContrast + // Try closer to original (less adjustment) + if (shouldLighten) high = mid else low = mid + } else { + // Need more adjustment (further from original) + if (shouldLighten) low = mid else high = mid } } + + if (bestContrast >= MIN_CONTRAST_RATIO) { + hsl[2] = bestL + return ColorUtils.HSLToColor(hsl) + } + + // Fallback: push to extreme lightness + hsl[2] = if (shouldLighten) 0.9f else 0.1f + return ColorUtils.HSLToColor(hsl) } +private const val MIN_CONTRAST_RATIO = 4.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') From b6ce3c4e710f0785af098a2eac53ea8555bd8c8c Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 023/349] fix(i18n): Replace hardcoded strings with string resources and translations --- .../flxrs/dankchat/chat/user/compose/UserPopupDialog.kt | 2 +- .../flxrs/dankchat/main/compose/ChatInputViewModel.kt | 9 +++++++++ .../com/flxrs/dankchat/main/compose/EmptyStateContent.kt | 2 +- .../com/flxrs/dankchat/main/compose/MainScreenDialogs.kt | 8 ++++++-- .../flxrs/dankchat/main/compose/SuggestionDropdown.kt | 4 ++-- app/src/main/res/values-be-rBY/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de-rDE/strings.xml | 2 ++ app/src/main/res/values-en-rAU/strings.xml | 2 ++ app/src/main/res/values-en-rGB/strings.xml | 2 ++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-fi-rFI/strings.xml | 2 ++ app/src/main/res/values-fr-rFR/strings.xml | 2 ++ app/src/main/res/values-hu-rHU/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja-rJP/strings.xml | 2 ++ app/src/main/res/values-pl-rPL/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt-rPT/strings.xml | 2 ++ app/src/main/res/values-ru-rRU/strings.xml | 2 ++ app/src/main/res/values-sr/strings.xml | 2 ++ app/src/main/res/values-tr-rTR/strings.xml | 2 ++ app/src/main/res/values-uk-rUA/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 26 files changed, 61 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index 51c884a6c..e5d83ca01 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -109,7 +109,7 @@ fun UserPopupDialog( } is UserPopupState.Error -> { Text( - text = "Error: ${s.throwable?.message}", + text = stringResource(R.string.error_with_message, s.throwable?.message.orEmpty()), modifier = Modifier.padding(horizontal = 16.dp) ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index a4e6c8e7c..749dc1fd1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.chat.suggestion.SuggestionProvider +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.ChatRepository @@ -25,6 +26,7 @@ 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.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -60,6 +62,7 @@ class ChatInputViewModel( private val streamDataRepository: StreamDataRepository, private val streamsSettingsDataStore: StreamsSettingsDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val notificationsSettingsDataStore: NotificationsSettingsDataStore, private val mainEventBus: MainEventBus, ) : ViewModel() { @@ -393,6 +396,12 @@ class ChatInputViewModel( } } + 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) { textFieldState.edit { append(text) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt index 5b838c8f9..1fdf908f4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt @@ -68,7 +68,7 @@ fun EmptyStateContent( AssistChip( onClick = onToggleAppBar, - label = { Text("Toggle App Bar") } + label = { Text(stringResource(R.string.toggle_app_bar)) } ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index c04cdf77e..e248a0a60 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -27,6 +27,7 @@ import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.chat.user.compose.UserPopupDialog import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog @@ -335,8 +336,11 @@ fun MainScreenDialogs( onBlockUser = viewModel::blockUser, onUnblockUser = viewModel::unblockUser, onDismiss = messageState.onDismissUserPopup, - onMention = { name, _ -> - chatInputViewModel.insertText("@$name ") + onMention = { name, displayName -> + chatInputViewModel.mentionUser( + user = UserName(name), + display = DisplayName(displayName), + ) }, onWhisper = { name -> chatInputViewModel.updateInputText("/w $name ") diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt index 17dbd320e..47fb69824 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt @@ -116,7 +116,7 @@ private fun SuggestionItem( is Suggestion.UserSuggestion -> { Icon( imageVector = Icons.Default.Person, - contentDescription = "User", + contentDescription = null, modifier = Modifier .size(32.dp) .padding(end = 12.dp) @@ -130,7 +130,7 @@ private fun SuggestionItem( is Suggestion.CommandSuggestion -> { Icon( imageVector = Icons.Default.Android, - contentDescription = "Command", + contentDescription = null, modifier = Modifier .size(32.dp) .padding(end = 12.dp) diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index b8ac333c4..fda68e8a9 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -401,4 +401,6 @@ Паказваць катэгорыю трансляцыі Таксама паказваць катэгорыю трансляцыі Пераключыць радок уводу + Пераключыць панэль праграмы + Памылка: %s diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 1105404ba..825f36345 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -291,4 +291,6 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Àlies personalitzat Escollir color d\'usuari personalitzat Afegeix un nom i color personalitzat per a usuaris + Mostra/amaga la barra d\'aplicació + Error: %s diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3b7cfaf40..04d6d8189 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -402,4 +402,6 @@ 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c554b23af..861d29998 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -417,4 +417,6 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Wähle benutzerdefinierte Highlight Farbe Standard Farbe wählen + App-Leiste umschalten + Fehler: %s diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 12cd63f48..011565ac0 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -228,4 +228,6 @@ 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 683ad99d0..3314833e5 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -229,4 +229,6 @@ 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b2b118f8e..f17c841f2 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -410,4 +410,6 @@ Pick custom highlight color Default Choose Color + Toggle App Bar + Error: %s diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index aad74fc7f..491d9f57c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -417,4 +417,6 @@ 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 098521678..26f15e525 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -255,4 +255,6 @@ 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 + Näytä/piilota sovelluspalkki + Virhe: %s diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 95cf40614..1f2183cc4 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -400,4 +400,6 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index b53273ada..06344734e 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -395,4 +395,6 @@ Stream kategória mutatása Stream kategória megjelenítése Beviteli mező kapcsolása + Alkalmazássáv kapcsolása + Hiba: %s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a48e6ca9b..1171c8351 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -384,4 +384,6 @@ %d mese %d mesi + Mostra/nascondi barra app + Errore: %s diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index acd5ac3f3..474be7517 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -382,4 +382,6 @@ %dヶ月 + アプリバーの切り替え + エラー: %s diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 1c383a6bf..b47dfd487 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -421,4 +421,6 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c59ab287a..08841f1eb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -395,4 +395,6 @@ Mostrar categoria da transmissão Também exibir categoria da transmissão Alternar entrada + Alternar barra do aplicativo + Erro: %s diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 5a641b632..4b1097b33 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -386,4 +386,6 @@ %d mês %d meses + Alternar barra da aplicação + Erro: %s diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3673afc04..649031bec 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -406,4 +406,6 @@ Отображать категорию стрима Также показывать категорию стрима Переключить строку ввода + Переключить панель приложения + Ошибка: %s diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 5cc5ac61a..cbc61d41d 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -196,4 +196,6 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Forsiraj engleski jezik Forisraj čitanje teksta na engleskom umesto na podrazumevanom jeziku Vidljivost emotova nezavisnih servisa + Prikaži/sakrij traku aplikacije + Greška: %s diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e91d07899..73c9cb6e3 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -417,4 +417,6 @@ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index bd8e91a12..d4313e93d 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -403,4 +403,6 @@ %d місяців %d місяців + Перемкнути панель додатку + Помилка: %s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9842af70..0ee1893c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -490,4 +490,6 @@ Pick custom highlight color Default Choose Color + Toggle App Bar + Error: %s From 5e8112c934f8075a76e36f5b3f698f7399554cba Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 024/349] feat(compose): Add gesture-based toolbar and input bar hide/show --- .github/workflows/android.yml | 4 +- .../dankchat/chat/compose/ChatComposable.kt | 4 + .../dankchat/chat/compose/ChatMessageText.kt | 2 +- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 16 +- .../chat/compose/ChatScrollBehavior.kt | 100 +++ .../chat/compose/messages/PrivMessage.kt | 5 +- .../compose/messages/WhisperAndRedemption.kt | 7 +- .../messages/common/SimpleMessageContainer.kt | 63 +- .../dankchat/chat/emotemenu/EmoteItem.kt | 2 + .../chat/emotemenu/EmoteMenuTabItem.kt | 3 + .../dankchat/data/repo/data/DataRepository.kt | 4 +- .../data/repo/emote/EmoteRepository.kt | 15 +- .../dankchat/domain/ChannelDataCoordinator.kt | 24 +- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 4 +- .../com/flxrs/dankchat/main/MainFragment.kt | 1 + .../com/flxrs/dankchat/main/MainViewModel.kt | 5 + .../compose/ChannelManagementViewModel.kt | 6 + .../main/compose/ChannelPagerViewModel.kt | 9 +- .../main/compose/ChannelTabViewModel.kt | 19 +- .../dankchat/main/compose/ChatBottomBar.kt | 111 +++ .../dankchat/main/compose/ChatInputLayout.kt | 20 +- .../main/compose/ChatInputViewModel.kt | 54 +- .../main/compose/DialogStateViewModel.kt | 91 ++ .../main/compose/EmoteMenuViewModel.kt | 5 +- .../dankchat/main/compose/FloatingToolbar.kt | 78 +- .../main/compose/FullScreenSheetOverlay.kt | 6 +- .../flxrs/dankchat/main/compose/MainAppBar.kt | 49 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 785 ++++++++++-------- .../main/compose/MainScreenDialogs.kt | 237 ++---- .../main/compose/MainScreenEventHandler.kt | 125 +++ .../main/compose/MainScreenViewModel.kt | 14 + .../flxrs/dankchat/main/compose/StreamView.kt | 17 +- .../dankchat/main/compose/StreamViewModel.kt | 23 + .../compose/dialogs/ConfirmationDialog.kt | 58 ++ .../compose/dialogs/ManageChannelsDialog.kt | 47 +- .../compose/dialogs/MessageOptionsDialog.kt | 50 +- .../main/compose/dialogs/RoomStateDialog.kt | 2 +- .../dankchat/main/compose/sheets/EmoteMenu.kt | 18 +- .../utils/compose/RoundedCornerPadding.kt | 30 + .../dankchat/utils/extensions/Extensions.kt | 20 + .../utils/extensions/StringExtensions.kt | 2 +- app/src/main/res/values-be-rBY/strings.xml | 8 + app/src/main/res/values-ca/strings.xml | 8 + app/src/main/res/values-cs/strings.xml | 8 + app/src/main/res/values-de-rDE/strings.xml | 8 + app/src/main/res/values-en-rAU/strings.xml | 8 + app/src/main/res/values-en-rGB/strings.xml | 8 + app/src/main/res/values-en/strings.xml | 8 + app/src/main/res/values-es-rES/strings.xml | 7 + app/src/main/res/values-fi-rFI/strings.xml | 8 + app/src/main/res/values-fr-rFR/strings.xml | 8 + app/src/main/res/values-hu-rHU/strings.xml | 8 + app/src/main/res/values-it/strings.xml | 8 + app/src/main/res/values-ja-rJP/strings.xml | 8 + app/src/main/res/values-pl-rPL/strings.xml | 7 + app/src/main/res/values-pt-rBR/strings.xml | 8 + app/src/main/res/values-pt-rPT/strings.xml | 8 + app/src/main/res/values-ru-rRU/strings.xml | 8 + app/src/main/res/values-sr/strings.xml | 8 + app/src/main/res/values-tr-rTR/strings.xml | 7 + app/src/main/res/values-uk-rUA/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + gradle/libs.versions.toml | 18 +- gradle/wrapper/gradle-wrapper.jar | Bin 46175 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 66 files changed, 1558 insertions(+), 764 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ConfirmationDialog.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c953cc440..299eda187 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -68,7 +68,7 @@ jobs: run: bash ./gradlew :app:assembleDebug - name: Upload APK artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: debug apk path: ${{ github.workspace }}/app/build/outputs/apk/debug/*.apk @@ -125,7 +125,7 @@ jobs: SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - name: Upload APK artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Signed release apk path: ${{ github.workspace }}/app/build/outputs/apk/release/*.apk diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 20eda7bb3..68e9640ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -40,6 +40,8 @@ fun ChatComposable( hasHelperText: Boolean = false, onRecover: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(), + scrollModifier: Modifier = Modifier, + onScrollToBottom: () -> Unit = {}, onScrollDirectionChanged: (Boolean) -> Unit = {}, ) { // Create ChatComposeViewModel with channel-specific key for proper scoping @@ -75,6 +77,8 @@ fun ChatComposable( hasHelperText = hasHelperText, onRecover = onRecover, contentPadding = contentPadding, + scrollModifier = scrollModifier, + onScrollToBottom = onScrollToBottom, onScrollDirectionChanged = onScrollDirectionChanged ) } // CompositionLocalProvider diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt index d8a405fa9..f381b865a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt @@ -91,7 +91,7 @@ fun ChatMessageText( } } - Box(modifier = modifier.padding(horizontal = 8.dp)) { + Box(modifier = modifier) { BasicText( text = annotatedString, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index edc8783f0..7296e11d0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -74,6 +74,8 @@ fun ChatScreen( hasHelperText: Boolean = false, onRecover: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(), + scrollModifier: Modifier = Modifier, + onScrollToBottom: () -> Unit = {}, onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, ) { val listState = rememberLazyListState() @@ -89,11 +91,14 @@ fun ChatScreen( } } - // Disable auto-scroll when user scrolls forward (up in chat) + // 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 + } onScrollDirectionChanged(listState.lastScrolledForward) } @@ -115,7 +120,7 @@ fun ChatScreen( state = listState, reverseLayout = true, contentPadding = contentPadding, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().then(scrollModifier) ) { items( items = reversedMessages, @@ -156,9 +161,9 @@ fun ChatScreen( val bottomContentPadding = contentPadding.calculateBottomPadding() val fabBottomPadding by animateDpAsState( targetValue = when { - showInput -> bottomContentPadding - hasHelperText -> 48.dp - else -> 24.dp + showInput -> bottomContentPadding + hasHelperText -> maxOf(bottomContentPadding, 48.dp) + else -> maxOf(bottomContentPadding, 24.dp) }, animationSpec = if (showInput) snap() else spring(), label = "fabBottomPadding" @@ -189,6 +194,7 @@ fun ChatScreen( onClick = { shouldAutoScroll = true onScrollDirectionChanged(false) + onScrollToBottom() }, ) { Icon( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt new file mode 100644 index 000000000..f268cea9c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt @@ -0,0 +1,100 @@ +package com.flxrs.dankchat.chat.compose + +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 [thresholdPx]. + * + * 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 + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + // Reset accumulator on direction change to avoid stale buildup + when { + accumulated > 0f && available.y < 0f -> accumulated = 0f + accumulated < 0f && available.y > 0f -> accumulated = 0f + } + accumulated += available.y + when { + accumulated > hideThresholdPx -> { onHide(); accumulated = 0f } + accumulated < -showThresholdPx -> { onShow(); accumulated = 0f } + } + return Offset.Zero + } +} + +/** + * Detects sustained overscroll at the bottom of a reversed list. + * Requires [frameThreshold] consecutive overscroll events (~16ms each + * during a drag) before calling [onReveal], so only a deliberate, + * sustained pull triggers it — not a scroll that merely reaches the end. + */ +fun overscrollRevealConnection(frameThreshold: Int, onReveal: () -> Unit): NestedScrollConnection { + return object : NestedScrollConnection { + private var consecutiveFrames = 0 + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (source == NestedScrollSource.UserInput && available.y < 0f) { + consecutiveFrames++ + if (consecutiveFrames >= frameThreshold) { + onReveal() + consecutiveFrames = 0 + } + } else { + consecutiveFrames = 0 + } + 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(enabled) { + awaitEachGesture { + val down = 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/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index bb5e74abe..1b13492d6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -81,7 +81,7 @@ fun PrivMessageComposable( .wrapContentHeight() .background(backgroundColor) .indication(interactionSource, ripple()) - .padding(horizontal = 8.dp, vertical = 2.dp) + .padding(vertical = 2.dp) ) { // Reply thread header if (message.thread != null) { @@ -116,6 +116,7 @@ fun PrivMessageComposable( showChannelPrefix = showChannelPrefix, animateGifs = animateGifs, interactionSource = interactionSource, + backgroundColor = backgroundColor, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick @@ -130,13 +131,13 @@ private fun PrivMessageText( 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 emoteCoordinator = LocalEmoteAnimationCoordinator.current - val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) val linkColor = MaterialTheme.colorScheme.primary diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 37840a4a9..5cc22c236 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -75,7 +75,7 @@ fun WhisperMessageComposable( .wrapContentHeight() .background(backgroundColor) .indication(interactionSource, ripple()) - .padding(horizontal = 8.dp, vertical = 2.dp) + .padding(vertical = 2.dp) .alpha(message.textAlpha) ) { Box(modifier = Modifier.weight(1f)) { @@ -83,6 +83,7 @@ fun WhisperMessageComposable( message = message, fontSize = fontSize, animateGifs = animateGifs, + backgroundColor = backgroundColor, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick @@ -109,13 +110,13 @@ 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 emoteCoordinator = LocalEmoteAnimationCoordinator.current - val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val senderColor = rememberNormalizedColor(message.rawSenderColor, backgroundColor) val recipientColor = rememberNormalizedColor(message.rawRecipientColor, backgroundColor) @@ -324,7 +325,7 @@ fun PointRedemptionMessageComposable( .fillMaxWidth() .wrapContentHeight() .background(backgroundColor) - .padding(horizontal = 8.dp, vertical = 2.dp) + .padding(vertical = 2.dp) .alpha(message.textAlpha) ) { Row( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt index 05e03f3d9..1a7212807 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt @@ -1,24 +1,39 @@ package com.flxrs.dankchat.chat.compose.messages.common +import android.util.Log +import androidx.browser.customtabs.CustomTabsIntent 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.foundation.text.ClickableText +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.LocalContext +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.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.chat.compose.ChatMessageText +import androidx.compose.ui.unit.em +import androidx.core.net.toUri +import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor /** * A simple message container for system messages, notices, and other simple message types. * Handles background color, padding, and text rendering consistently. + * Supports clickable URLs in the message text. */ +@Suppress("DEPRECATION") @Composable fun SimpleMessageContainer( message: String, @@ -31,6 +46,29 @@ fun SimpleMessageContainer( ) { val bgColor = rememberBackgroundColor(lightBackgroundColor, darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) + val linkColor = MaterialTheme.colorScheme.primary + val timestampColor = MaterialTheme.colorScheme.onSurface + val context = LocalContext.current + + val annotatedString = remember(message, timestamp, textColor, linkColor, timestampColor, fontSize) { + buildAnnotatedString { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = fontSize * 0.95f, + color = timestampColor, + letterSpacing = (-0.03).em, + ) + ) { + append(timestamp) + } + append(" ") + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(message, linkColor) + } + } + } Box( modifier = modifier @@ -40,11 +78,22 @@ fun SimpleMessageContainer( .padding(vertical = 2.dp) .alpha(textAlpha) ) { - ChatMessageText( - text = message, - timestamp = timestamp, - fontSize = fontSize, - textColor = textColor, + ClickableText( + text = annotatedString, + modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + annotatedString.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, annotation.item.toUri()) + } catch (e: Exception) { + Log.e("SimpleMessageContainer", "Error launching URL", e) + } + } + } ) } -} \ No newline at end of file +} 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 index 0a8d65c2f..f70782230 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt @@ -1,7 +1,9 @@ package com.flxrs.dankchat.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 { 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 index 18549c19f..d9fd3fd8c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt @@ -1,3 +1,6 @@ package com.flxrs.dankchat.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/data/repo/data/DataRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataRepository.kt index 5386f78a6..e1f7e536f 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 @@ -147,8 +147,8 @@ class DataRepository( } } - suspend fun loadUserEmotes(userId: UserId) { - emoteRepository.loadUserEmotes(userId) + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) { + emoteRepository.loadUserEmotes(userId, onFirstPageLoaded) } suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) { 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 d0ab31f95..b349252f1 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 @@ -319,9 +319,9 @@ class EmoteRepository( fun getSevenTVUserDetails(channel: UserName): SevenTVUserDetails? = sevenTvChannelDetails[channel] - suspend fun loadUserEmotes(userId: UserId) { + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) { try { - loadUserEmotesViaHelix(userId) + loadUserEmotesViaHelix(userId, onFirstPageLoaded) } catch (e: HelixApiException) { if (e.error is HelixError.MissingScopes) { // Fallback to old path if the user hasn't re-logged with the new scope @@ -331,10 +331,14 @@ class EmoteRepository( } } - private suspend fun loadUserEmotesViaHelix(userId: UserId) = withContext(Dispatchers.Default) { + private suspend fun loadUserEmotesViaHelix( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null + ) = withContext(Dispatchers.Default) { val seenIds = HashSet() val allEmotes = mutableListOf() var totalCount = 0 + var isFirstPage = true helixApiClient.getUserEmotesFlow(userId).collect { page -> totalCount += page.size @@ -390,6 +394,11 @@ class EmoteRepository( allEmotes.addAll(newGlobalEmotes) globalEmoteState.update { it.copy(twitchEmotes = allEmotes.toList()) } } + + if (isFirstPage) { + isFirstPage = false + onFirstPageLoaded?.invoke() + } } Log.d(TAG, "Helix getUserEmotes: $totalCount total, ${seenIds.size} unique, ${allEmotes.size} resolved") diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index cb132fad1..20a41bc46 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -87,11 +88,18 @@ class ChannelDataCoordinator( // Reparse after global emotes load so 3rd party globals are visible immediately chatRepository.reparseAllEmotesAndBadges() - // Load user state emotes if logged in (slow, paginated) + // Load user emotes if logged in — only block on first page, rest loads async if (preferenceStore.isLoggedIn) { - loadUserStateEmotesIfAvailable() - // Reparse again after user emotes finish so they become visible - chatRepository.reparseAllEmotesAndBadges() + val userId = preferenceStore.userIdString + if (userId != null) { + val firstPageLoaded = CompletableDeferred() + launch { + globalDataLoader.loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } + chatRepository.reparseAllEmotesAndBadges() + } + firstPageLoaded.await() + chatRepository.reparseAllEmotesAndBadges() + } } val failures = dataRepository.dataLoadingFailures.value @@ -105,14 +113,6 @@ class ChannelDataCoordinator( } } - /** - * Load user-specific emotes after global data is loaded - */ - private suspend fun loadUserStateEmotesIfAvailable() { - val userId = preferenceStore.userIdString ?: return - globalDataLoader.loadUserEmotes(userId) - } - /** * Cleanup when a channel is removed */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 3a3888fbf..353a21e1f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -49,8 +49,8 @@ class GlobalDataLoader( /** * Load user-specific global emotes via Helix API (requires login + user:read:emotes scope) */ - suspend fun loadUserEmotes(userId: UserId) { - dataRepository.loadUserEmotes(userId) + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) { + dataRepository.loadUserEmotes(userId, onFirstPageLoaded) } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt index a2325edae..7c457054e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt @@ -985,6 +985,7 @@ class MainFragment : Fragment() { is ImageUploadState.Finished -> { val clipboard = getSystemService(requireContext(), ClipboardManager::class.java) clipboard?.setPrimaryClip(ClipData.newPlainText(CLIPBOARD_LABEL, result.url)) + mainViewModel.postSystemMessage(getString(R.string.system_message_upload_complete, result.url)) showSnackBar( message = getString(R.string.snackbar_image_uploaded, result.url), action = getString(R.string.snackbar_paste) to { insertText(result.url) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt index 911bdb896..1afe80a6f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt @@ -707,6 +707,11 @@ class MainViewModel( } } + fun postSystemMessage(message: String) { + val channel = activeChannel.value ?: return + chatRepository.makeAndPostCustomSystemMessage(message, channel) + } + fun fetchStreamData(channels: List? = this.channels.value) { cancelStreamData() channels?.ifEmpty { null } ?: return diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index bcd1ed21d..5ecf287d5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -113,6 +113,12 @@ class ChannelManagementViewModel( } } + fun selectChannel(channel: UserName) { + chatRepository.setActiveChannel(channel) + chatRepository.clearUnreadMessage(channel) + chatRepository.clearMentionCount(channel) + } + fun applyChanges(updatedChannels: List) { val currentChannels = preferenceStore.channels val newChannelNames = updatedChannels.map { it.channel } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt index 38bb8bb61..7456d80f8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -2,9 +2,13 @@ package com.flxrs.dankchat.main.compose import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository 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 @@ -22,7 +26,7 @@ class ChannelPagerViewModel( chatRepository.activeChannel, ) { channels, active -> ChannelPagerUiState( - channels = channels.map { it.channel }, + channels = channels.map { it.channel }.toImmutableList(), currentPage = channels.indexOfFirst { it.channel == active } .coerceAtLeast(0) ) @@ -39,7 +43,8 @@ class ChannelPagerViewModel( } } +@Immutable data class ChannelPagerUiState( - val channels: List = emptyList(), + val channels: ImmutableList = persistentListOf(), val currentPage: Int = 0 ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt index 90afcc00a..3875dede4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt @@ -2,11 +2,16 @@ package com.flxrs.dankchat.main.compose import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.domain.ChannelDataCoordinator 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 @@ -36,8 +41,9 @@ class ChannelTabViewModel( chatRepository.activeChannel, chatRepository.unreadMessagesMap, chatRepository.channelMentionCount, - combine(loadingFlows) { it.toList() } - ) { active, unread, mentions, loadingStates -> + combine(loadingFlows) { it.toList() }, + channelDataCoordinator.globalLoadingState + ) { active, unread, mentions, loadingStates, globalState -> val tabs = channels.mapIndexed { index, channelWithRename -> ChannelTabItem( channel = channelWithRename.channel, @@ -50,11 +56,12 @@ class ChannelTabViewModel( ) } ChannelTabUiState( - tabs = tabs, + tabs = tabs.toImmutableList(), selectedIndex = channels .indexOfFirst { it.channel == active } .coerceAtLeast(0), - loading = tabs.any { it.loadingState == ChannelLoadingState.Loading }, + loading = globalState == GlobalLoadingState.Loading + || tabs.any { it.loadingState == ChannelLoadingState.Loading }, ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) @@ -70,12 +77,14 @@ class ChannelTabViewModel( } } +@Immutable data class ChannelTabUiState( - val tabs: List = emptyList(), + val tabs: ImmutableList = persistentListOf(), val selectedIndex: Int = 0, val loading: Boolean = true, ) +@Immutable data class ChannelTabItem( val channel: UserName, val displayName: String, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt new file mode 100644 index 000000000..e6e801d6b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -0,0 +1,111 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Column +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.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatBottomBar( + showInput: Boolean, + textFieldState: TextFieldState, + inputState: ChatInputUiState, + isUploading: Boolean, + isLoading: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + isStreamActive: Boolean, + hasStreamData: Boolean, + isSheetOpen: Boolean, + onSend: () -> Unit, + onLastMessageClick: () -> Unit, + onEmoteClick: () -> Unit, + onWhisperDismiss: () -> Unit, + onReplyDismiss: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + onToggleStream: () -> Unit, + onChangeRoomState: () -> Unit, + onNewWhisper: (() -> Unit)?, + onInputHeightChanged: (Int) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + AnimatedVisibility( + visible = showInput, + enter = EnterTransition.None, + exit = slideOutVertically(targetOffsetY = { it }), + ) { + ChatInputLayout( + textFieldState = textFieldState, + inputState = inputState.inputState, + enabled = inputState.enabled, + canSend = inputState.canSend, + showReplyOverlay = inputState.showReplyOverlay, + replyName = inputState.replyName, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + helperText = inputState.helperText, + isUploading = isUploading, + isLoading = isLoading, + isFullscreen = isFullscreen, + isModerator = isModerator, + isStreamActive = isStreamActive, + hasStreamData = hasStreamData, + onSend = onSend, + onLastMessageClick = onLastMessageClick, + onEmoteClick = onEmoteClick, + showWhisperOverlay = inputState.showWhisperOverlay, + whisperTarget = inputState.whisperTarget, + onWhisperDismiss = onWhisperDismiss, + onReplyDismiss = onReplyDismiss, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + onToggleStream = onToggleStream, + onChangeRoomState = onChangeRoomState, + onNewWhisper = onNewWhisper, + showQuickActions = !isSheetOpen, + modifier = Modifier.onGloballyPositioned { coordinates -> + onInputHeightChanged(coordinates.size.height) + } + ) + } + + // Sticky helper text + nav bar spacer when input is hidden + if (!showInput) { + val helperText = inputState.helperText + if (!helperText.isNullOrEmpty()) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = helperText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .basicMarquee(), + textAlign = TextAlign.Start + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 98fee3e7a..0d4d2cc4e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -31,7 +31,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit -import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Keyboard import androidx.compose.material.icons.filled.MoreVert @@ -51,6 +51,7 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable 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 @@ -59,6 +60,7 @@ 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.layout.layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -86,6 +88,7 @@ fun ChatInputLayout( isEmoteMenuOpen: Boolean, helperText: String?, isUploading: Boolean, + isLoading: Boolean, isFullscreen: Boolean, isModerator: Boolean, isStreamActive: Boolean, @@ -106,6 +109,7 @@ fun ChatInputLayout( modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } + var maxTextFieldHeight by remember { mutableIntStateOf(0) } val hint = when (inputState) { InputState.Default -> stringResource(R.string.hint_connected) InputState.Replying -> stringResource(R.string.hint_replying) @@ -230,6 +234,14 @@ fun ChatInputLayout( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val height = maxOf(placeable.height, maxTextFieldHeight) + maxTextFieldHeight = height + layout(placeable.width, height) { + placeable.placeRelative(0, 0) + } + } .padding(bottom = 0.dp), // Reduce bottom padding as actions are below label = { Text(hint) }, colors = textFieldColors, @@ -262,9 +274,9 @@ fun ChatInputLayout( ) } - // Upload progress indicator + // Progress indicator for uploads and data loading AnimatedVisibility( - visible = isUploading, + visible = isUploading || isLoading, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { @@ -329,7 +341,7 @@ fun ChatInputLayout( modifier = Modifier.size(40.dp) ) { Icon( - imageVector = Icons.Default.Add, + imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.whisper_new), ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 749dc1fd1..809aaaf7a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -3,6 +3,7 @@ package com.flxrs.dankchat.main.compose 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.Immutable import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -28,6 +29,9 @@ import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -128,14 +132,6 @@ class ChatInputViewModel( }.distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - val hasStreamData: StateFlow = combine( - chatRepository.activeChannel, - streamDataRepository.streamData - ) { activeChannel, streamData -> - activeChannel != null && streamData.any { it.channel == activeChannel } - }.distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - private var _uiState: StateFlow? = null init { @@ -171,19 +167,6 @@ class ChatInputViewModel( } } - // Trigger stream data fetching whenever channels change - viewModelScope.launch { - chatRepository.channels.collect { channels -> - if (channels != null) { - streamDataRepository.fetchStreamData(channels) - } - } - } - } - - override fun onCleared() { - super.onCleared() - streamDataRepository.cancelStreamData() } private data class UiDependencies( @@ -205,9 +188,19 @@ class ChatInputViewModel( val whisperTarget: UserName? ) - fun uiState(fullScreenSheetState: StateFlow, mentionSheetTab: StateFlow): StateFlow { + fun uiState(externalSheetState: StateFlow, externalMentionTab: StateFlow): StateFlow { if (_uiState != null) return _uiState!! + // 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, @@ -230,8 +223,8 @@ class ChatInputViewModel( } val inputOverlayFlow = combine( - fullScreenSheetState, - mentionSheetTab, + externalSheetState, + externalMentionTab, replyStateFlow, _isEmoteMenuOpen, _whisperTarget @@ -245,9 +238,6 @@ class ChatInputViewModel( inputOverlayFlow, helperText ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget), helperText -> - this.fullScreenSheetState.value = sheetState - this.mentionSheetTab.value = tab - val isMentionsTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 0 val isWhisperTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 1 val isInReplyThread = sheetState is FullScreenSheetState.Replies @@ -280,7 +270,7 @@ class ChatInputViewModel( text = text, canSend = canSend, enabled = enabled, - suggestions = suggestions, + suggestions = suggestions.toImmutableList(), activeChannel = activeChannel, connectionState = connectionState, isLoggedIn = isLoggedIn, @@ -409,6 +399,11 @@ class ChatInputViewModel( } } + fun postSystemMessage(message: String) { + val channel = chatRepository.activeChannel.value ?: return + chatRepository.makeAndPostCustomSystemMessage(message, channel) + } + fun updateInputText(text: String) { textFieldState.edit { replace(0, length, text) @@ -457,11 +452,12 @@ class ChatInputViewModel( } } +@Immutable data class ChatInputUiState( val text: String = "", val canSend: Boolean = false, val enabled: Boolean = false, - val suggestions: List = emptyList(), + val suggestions: ImmutableList = persistentListOf(), val activeChannel: UserName? = null, val connectionState: ConnectionState = ConnectionState.DISCONNECTED, val isLoggedIn: Boolean = false, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt new file mode 100644 index 000000000..b4fe31e47 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt @@ -0,0 +1,91 @@ +package com.flxrs.dankchat.main.compose + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams +import com.flxrs.dankchat.chat.user.UserPopupStateParams +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class DialogStateViewModel : ViewModel() { + + private val _state = MutableStateFlow(DialogState()) + val state: StateFlow = _state.asStateFlow() + + // 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 showClearChat() { update { copy(showClearChat = true) } } + fun dismissClearChat() { update { copy(showClearChat = false) } } + + fun showRoomState() { update { copy(showRoomState = true) } } + fun dismissRoomState() { update { copy(showRoomState = false) } } + + // Auth dialogs + fun showLogout() { update { copy(showLogout = true) } } + fun dismissLogout() { update { copy(showLogout = false) } } + + fun showLoginOutdated(username: UserName) { update { copy(loginOutdated = username) } } + fun dismissLoginOutdated() { update { copy(loginOutdated = null) } } + + fun showLoginExpired() { update { copy(showLoginExpired = true) } } + fun dismissLoginExpired() { update { copy(showLoginExpired = false) } } + + // Whisper dialog + fun showNewWhisper() { update { copy(showNewWhisper = true) } } + fun dismissNewWhisper() { update { copy(showNewWhisper = false) } } + + // Upload + fun setPendingUploadAction(action: (() -> Unit)?) { update { copy(pendingUploadAction = action) } } + fun setUploading(uploading: Boolean) { update { copy(isUploading = uploading) } } + + // Message interactions + fun showUserPopup(params: UserPopupStateParams) { 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) } } + + private inline fun update(crossinline transform: DialogState.() -> DialogState) { + _state.value = _state.value.transform() + } +} + +@Stable +data class DialogState( + val showAddChannel: Boolean = false, + val showManageChannels: Boolean = false, + val showRemoveChannel: Boolean = false, + val showBlockChannel: Boolean = false, + val showClearChat: Boolean = false, + val showRoomState: Boolean = false, + val showLogout: Boolean = false, + val loginOutdated: UserName? = null, + val showLoginExpired: 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, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt index 942d679de..4a298409f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.main.compose import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.emotemenu.EmoteItem import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTabItem import com.flxrs.dankchat.data.repo.chat.ChatRepository @@ -11,8 +10,8 @@ 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.utils.extensions.flatMapLatestOrDefault -import com.flxrs.dankchat.utils.extensions.moveToFront 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.Dispatchers @@ -67,7 +66,7 @@ class EmoteMenuViewModel( } listOf( async { EmoteMenuTabItem(EmoteMenuTab.RECENT, availableRecents.toEmoteItems()) }, - async { EmoteMenuTabItem(EmoteMenuTab.SUBS, (groupedByType[EmoteMenuTab.SUBS] ?: emptyList()).moveToFront(channel).toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.SUBS, groupedByType[EmoteMenuTab.SUBS].toEmoteItemsWithFront(channel)) }, async { EmoteMenuTabItem(EmoteMenuTab.CHANNEL, (groupedByType[EmoteMenuTab.CHANNEL] ?: emptyList()).toEmoteItems()) }, async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, (groupedByType[EmoteMenuTab.GLOBAL] ?: emptyList()).toEmoteItems()) } ).awaitAll().toImmutableList() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index bc1dd10b3..b3b2f3368 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -53,6 +53,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -75,6 +76,29 @@ import androidx.compose.ui.layout.onSizeChanged import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +sealed interface ToolbarAction { + data class SelectTab(val index: Int) : ToolbarAction + data class LongClickTab(val index: Int) : 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 ClearChat : ToolbarAction + data object ToggleStream : ToolbarAction + data object OpenSettings : ToolbarAction +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun FloatingToolbar( @@ -87,29 +111,10 @@ fun FloatingToolbar( hasStreamData: Boolean, streamHeightDp: Dp, totalMentionCount: Int, - onTabSelected: (Int) -> Unit, - onTabLongClick: (Int) -> Unit, - onAddChannel: () -> Unit, - onOpenMentions: () -> Unit, - // Overflow menu callbacks - onLogin: () -> Unit, - onRelogin: () -> Unit, - onLogout: () -> Unit, - onManageChannels: () -> Unit, - onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, - onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, - onClearChat: () -> Unit, - onToggleStream: () -> Unit, - onOpenSettings: () -> Unit, + onAction: (ToolbarAction) -> Unit, endAligned: Boolean = false, showTabs: Boolean = true, + streamToolbarAlpha: Float = 1f, modifier: Modifier = Modifier, ) { if (tabState.tabs.isEmpty()) return @@ -172,15 +177,17 @@ fun FloatingToolbar( ) } + 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 (currentStream != null && streamHeightDp > 0.dp) streamHeightDp + 8.dp else 0.dp) + .padding(top = if (hasStream) streamHeightDp + 8.dp else 0.dp) + .graphicsLayer { alpha = streamToolbarAlpha } ) { - val hasStream = currentStream != null && streamHeightDp > 0.dp val scrimColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) val statusBarPx = with(density) { WindowInsets.statusBars.getTop(density).toFloat() } var toolbarRowHeight by remember { mutableStateOf(0f) } @@ -312,9 +319,9 @@ fun FloatingToolbar( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .combinedClickable( - onClick = { onTabSelected(index) }, + onClick = { onAction(ToolbarAction.SelectTab(index)) }, onLongClick = { - onTabLongClick(index) + onAction(ToolbarAction.LongClickTab(index)) overflowInitialMenu = AppBarMenu.Channel showOverflowMenu = true } @@ -370,13 +377,13 @@ fun FloatingToolbar( color = MaterialTheme.colorScheme.surfaceContainer, ) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onAddChannel) { + IconButton(onClick = { onAction(ToolbarAction.AddChannel) }) { Icon( imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add_channel) ) } - IconButton(onClick = onOpenMentions) { + IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { Icon( imageVector = Icons.Default.Notifications, contentDescription = stringResource(R.string.mentions_title), @@ -421,22 +428,7 @@ fun FloatingToolbar( overflowInitialMenu = AppBarMenu.Main }, initialMenu = overflowInitialMenu, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = onLogout, - onManageChannels = onManageChannels, - onOpenChannel = onOpenChannel, - onRemoveChannel = onRemoveChannel, - onReportChannel = onReportChannel, - onBlockChannel = onBlockChannel, - onCaptureImage = onCaptureImage, - onCaptureVideo = onCaptureVideo, - onChooseMedia = onChooseMedia, - onReloadEmotes = onReloadEmotes, - onReconnect = onReconnect, - onClearChat = onClearChat, - onToggleStream = onToggleStream, - onOpenSettings = onOpenSettings + onAction = onAction, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index 27bbd230b..cd4196527 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flxrs.dankchat.chat.compose.BadgeUi @@ -38,10 +39,11 @@ fun FullScreenSheetOverlay( bottomContentPadding: Dp = 0.dp, modifier: Modifier = Modifier, ) { + val bottomContentPaddingPx = with(LocalDensity.current) { bottomContentPadding.roundToPx() } AnimatedVisibility( visible = sheetState !is FullScreenSheetState.Closed, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + enter = slideInVertically(initialOffsetY = { it - bottomContentPaddingPx }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it - bottomContentPaddingPx }) + fadeOut(), modifier = modifier.fillMaxSize() ) { Box( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index be10f65eb..174bccc99 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -461,23 +461,8 @@ fun InlineOverflowMenu( isStreamActive: Boolean = false, hasStreamData: Boolean = false, onDismiss: () -> Unit, - onLogin: () -> Unit, - onRelogin: () -> Unit, - onLogout: () -> Unit, - onManageChannels: () -> Unit, - onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, - onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, - onClearChat: () -> Unit, - onToggleStream: () -> Unit = {}, - onOpenSettings: () -> Unit, initialMenu: AppBarMenu = AppBarMenu.Main, + onAction: (ToolbarAction) -> Unit, ) { var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } @@ -496,44 +481,44 @@ fun InlineOverflowMenu( when (menu) { AppBarMenu.Main -> { if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login)) { onLogin(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.login)) { onAction(ToolbarAction.Login); onDismiss() } } else { InlineMenuItem(text = stringResource(R.string.account), hasSubMenu = true) { currentMenu = AppBarMenu.Account } } - InlineMenuItem(text = stringResource(R.string.manage_channels)) { onManageChannels(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.manage_channels)) { onAction(ToolbarAction.ManageChannels); onDismiss() } InlineMenuItem(text = stringResource(R.string.channel), hasSubMenu = true) { currentMenu = AppBarMenu.Channel } InlineMenuItem(text = stringResource(R.string.upload_media), hasSubMenu = true) { currentMenu = AppBarMenu.Upload } InlineMenuItem(text = stringResource(R.string.more), hasSubMenu = true) { currentMenu = AppBarMenu.More } - InlineMenuItem(text = stringResource(R.string.settings)) { onOpenSettings(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.settings)) { onAction(ToolbarAction.OpenSettings); onDismiss() } } AppBarMenu.Account -> { InlineSubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.relogin)) { onRelogin(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.logout)) { onLogout(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.relogin)) { onAction(ToolbarAction.Relogin); onDismiss() } + InlineMenuItem(text = stringResource(R.string.logout)) { onAction(ToolbarAction.Logout); onDismiss() } } AppBarMenu.Channel -> { InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) if (hasStreamData || isStreamActive) { - InlineMenuItem(text = stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) { onToggleStream(); onDismiss() } + InlineMenuItem(text = stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) { onAction(ToolbarAction.ToggleStream); onDismiss() } } - InlineMenuItem(text = stringResource(R.string.open_channel)) { onOpenChannel(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.remove_channel)) { onRemoveChannel(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.report_channel)) { onReportChannel(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.open_channel)) { onAction(ToolbarAction.OpenChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.remove_channel)) { onAction(ToolbarAction.RemoveChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.report_channel)) { onAction(ToolbarAction.ReportChannel); onDismiss() } if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel)) { onBlockChannel(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.block_channel)) { onAction(ToolbarAction.BlockChannel); onDismiss() } } } AppBarMenu.Upload -> { InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.take_picture)) { onCaptureImage(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.record_video)) { onCaptureVideo(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.choose_media)) { onChooseMedia(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.take_picture)) { onAction(ToolbarAction.CaptureImage); onDismiss() } + InlineMenuItem(text = stringResource(R.string.record_video)) { onAction(ToolbarAction.CaptureVideo); onDismiss() } + InlineMenuItem(text = stringResource(R.string.choose_media)) { onAction(ToolbarAction.ChooseMedia); onDismiss() } } AppBarMenu.More -> { InlineSubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.reload_emotes)) { onReloadEmotes(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.reconnect)) { onReconnect(); onDismiss() } - InlineMenuItem(text = stringResource(R.string.clear_chat)) { onClearChat(); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reload_emotes)) { onAction(ToolbarAction.ReloadEmotes); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reconnect)) { onAction(ToolbarAction.Reconnect); onDismiss() } + InlineMenuItem(text = stringResource(R.string.clear_chat)) { onAction(ToolbarAction.ClearChat); onDismiss() } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index b31310f21..7ca022624 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -2,13 +2,17 @@ package com.flxrs.dankchat.main.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.ExperimentalFoundationApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,17 +34,22 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.systemGestures +import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -53,8 +62,10 @@ import androidx.compose.runtime.mutableIntStateOf 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.AbsoluteAlignment import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -63,6 +74,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -70,12 +82,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.max 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 kotlinx.coroutines.delay import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import android.app.Activity @@ -84,16 +97,17 @@ import android.os.Build import android.util.Rational import androidx.navigation.compose.currentBackStackEntryAsState import com.flxrs.dankchat.R +import androidx.compose.ui.input.nestedscroll.nestedScroll import com.flxrs.dankchat.chat.compose.ChatComposable +import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker +import com.flxrs.dankchat.chat.compose.overscrollRevealConnection +import com.flxrs.dankchat.chat.compose.swipeDownToHide +import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.state.GlobalLoadingState -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.main.MainActivity -import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.main.compose.sheets.EmoteMenu import com.flxrs.dankchat.preferences.DankChatPreferenceStore @@ -101,6 +115,7 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.preferences.components.DankBackground +import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding import kotlinx.coroutines.launch import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -133,6 +148,7 @@ fun MainScreen( val resources = LocalResources.current val context = LocalContext.current val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current // Scoped ViewModels - each handles one concern val mainScreenViewModel: MainScreenViewModel = koinViewModel() val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() @@ -141,7 +157,8 @@ fun MainScreen( val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() val streamViewModel: StreamViewModel = koinViewModel() - val mentionViewModel: com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel = koinViewModel() + val dialogViewModel: DialogStateViewModel = koinViewModel() + val mentionViewModel: MentionComposeViewModel = koinViewModel() val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() val developerSettingsDataStore: DeveloperSettingsDataStore = koinInject() val preferenceStore: DankChatPreferenceStore = koinInject() @@ -169,7 +186,7 @@ fun MainScreen( val targetImeHeight = (imeTarget.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) val isImeOpening = targetImeHeight > 0 - val imeHeightState = androidx.compose.runtime.rememberUpdatedState(currentImeHeight) + val imeHeightState = rememberUpdatedState(currentImeHeight) val isImeVisible = WindowInsets.isImeVisible LaunchedEffect(isLandscape, density) { @@ -208,8 +225,35 @@ fun MainScreen( // Stream state val currentStream by streamViewModel.currentStreamedChannel.collectAsStateWithLifecycle() - val hasStreamData by chatInputViewModel.hasStreamData.collectAsStateWithLifecycle() + val hasStreamData by streamViewModel.hasStreamData.collectAsStateWithLifecycle() var streamHeightDp by remember { mutableStateOf(0.dp) } + val streamToolbarAlpha = remember { Animatable(0f) } + val hasVisibleStream = currentStream != null && streamHeightDp > 0.dp + var prevHasVisibleStream by remember { mutableStateOf(false) } + // Detect keyboard starting to close while stream exists + val imeTargetBottom = with(density) { WindowInsets.imeAnimationTarget.getBottom(density) } + val isKeyboardClosingWithStream = currentStream != null && isKeyboardVisible && imeTargetBottom == 0 + // Bridge the gap between keyboard fully closed and stream measured + var wasKeyboardClosingWithStream by remember { mutableStateOf(false) } + if (isKeyboardClosingWithStream) wasKeyboardClosingWithStream = true + if (hasVisibleStream) wasKeyboardClosingWithStream = false + // Fade on stream visibility changes (keyboard show/hide in stacked layout) + LaunchedEffect(hasVisibleStream, isKeyboardClosingWithStream) { + when { + isKeyboardClosingWithStream -> { + streamToolbarAlpha.animateTo(0f, tween(durationMillis = 150)) + } + hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { + prevHasVisibleStream = hasVisibleStream + streamToolbarAlpha.snapTo(0f) + streamToolbarAlpha.animateTo(1f, tween(durationMillis = 350)) + } + !hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { + prevHasVisibleStream = hasVisibleStream + streamToolbarAlpha.snapTo(0f) + } + } + } LaunchedEffect(currentStream) { if (currentStream == null) streamHeightDp = 0.dp } @@ -219,7 +263,7 @@ fun MainScreen( var isInPipMode by remember { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, _ -> + val observer = LifecycleEventObserver { _, _ -> isInPipMode = activity?.isInPictureInPictureMode == true } lifecycleOwner.lifecycle.addObserver(observer) @@ -259,21 +303,7 @@ fun MainScreen( } } - var showAddChannelDialog by remember { mutableStateOf(false) } - var showManageChannelsDialog by remember { mutableStateOf(false) } - var showLogoutDialog by remember { mutableStateOf(false) } - var showRemoveChannelDialog by remember { mutableStateOf(false) } - var showBlockChannelDialog by remember { mutableStateOf(false) } - var showClearChatDialog by remember { mutableStateOf(false) } - var userPopupParams by remember { mutableStateOf(null) } - var messageOptionsParams by remember { mutableStateOf(null) } - var emoteInfoEmotes by remember { mutableStateOf?>(null) } - var showRoomStateDialog by remember { mutableStateOf(false) } - var pendingUploadAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var isUploading by remember { mutableStateOf(false) } - var showLoginOutdatedDialog by remember { mutableStateOf(null) } - var showLoginExpiredDialog by remember { mutableStateOf(false) } - var showNewWhisperDialog by remember { mutableStateOf(false) } + val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() val toolsSettingsDataStore: ToolsSettingsDataStore = koinInject() val userStateRepository: UserStateRepository = koinInject() @@ -282,158 +312,56 @@ fun MainScreen( val isSheetOpen = fullScreenSheetState !is FullScreenSheetState.Closed val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - mainEventBus.events.collect { event -> - when (event) { - is MainEvent.LogOutRequested -> showLogoutDialog = true - is MainEvent.UploadLoading -> isUploading = true - is MainEvent.UploadSuccess -> { - isUploading = false - val result = snackbarHostState.showSnackbar( - message = resources.getString(R.string.snackbar_image_uploaded, event.url), - actionLabel = resources.getString(R.string.snackbar_paste), - duration = SnackbarDuration.Long - ) - if (result == SnackbarResult.ActionPerformed) { - chatInputViewModel.insertText(event.url) - } - } - is MainEvent.UploadFailed -> { - isUploading = 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.Long) - } - is MainEvent.LoginValidated -> { - snackbarHostState.showSnackbar( - message = resources.getString(R.string.snackbar_login, event.username), - duration = SnackbarDuration.Short - ) - } - is MainEvent.LoginOutdated -> { - showLoginOutdatedDialog = event.username - } - MainEvent.LoginTokenInvalid -> { - showLoginExpiredDialog = true - } - MainEvent.LoginValidationFailed -> { - snackbarHostState.showSnackbar( - message = resources.getString(R.string.oauth_verify_failed), - duration = SnackbarDuration.Short - ) - } - is MainEvent.OpenChannel -> { - channelTabViewModel.selectTab( - preferenceStore.channels.indexOf(event.channel) - ) - (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) - } - else -> Unit - } - } - } - - // Handle Login Result - val navBackStackEntry = navController.currentBackStackEntry - val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } - LaunchedEffect(loginSuccess) { - if (loginSuccess == true) { - channelManagementViewModel.reconnect() - mainScreenViewModel.reloadGlobalData() - navBackStackEntry?.savedStateHandle?.remove("login_success") - scope.launch { - val name = preferenceStore.userName - val message = if (name != null) { - resources.getString(R.string.snackbar_login, name) - } else { - resources.getString(R.string.login) // Fallback - } - snackbarHostState.showSnackbar(message) - } - } - } - - // Handle data loading errors - val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() - LaunchedEffect(loadingState) { - if (loadingState is GlobalLoadingState.Failed) { - val state = loadingState as GlobalLoadingState.Failed - scope.launch { - snackbarHostState.showSnackbar( - message = state.message, - actionLabel = resources.getString(R.string.snackbar_retry), - duration = SnackbarDuration.Long - ) - } - } - } + MainScreenEventHandler( + navController = navController, + resources = resources, + snackbarHostState = snackbarHostState, + mainEventBus = mainEventBus, + dialogViewModel = dialogViewModel, + chatInputViewModel = chatInputViewModel, + channelTabViewModel = channelTabViewModel, + channelManagementViewModel = channelManagementViewModel, + mainScreenViewModel = mainScreenViewModel, + preferenceStore = preferenceStore, + ) val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel MainScreenDialogs( - channelState = ChannelDialogState( - showAddChannel = showAddChannelDialog, - showManageChannels = showManageChannelsDialog, - showRemoveChannel = showRemoveChannelDialog, - showBlockChannel = showBlockChannelDialog, - showClearChat = showClearChatDialog, - showRoomState = showRoomStateDialog, - activeChannel = activeChannel, - roomStateChannel = inputState.activeChannel, - onDismissAddChannel = { showAddChannelDialog = false }, - onDismissManageChannels = { showManageChannelsDialog = false }, - onDismissRemoveChannel = { showRemoveChannelDialog = false }, - onDismissBlockChannel = { showBlockChannelDialog = false }, - onDismissClearChat = { showClearChatDialog = false }, - onDismissRoomState = { showRoomStateDialog = false }, - onAddChannel = { - channelManagementViewModel.addChannel(it) - showAddChannelDialog = false - }, - ), - authState = AuthDialogState( - showLogout = showLogoutDialog, - showLoginOutdated = showLoginOutdatedDialog != null, - showLoginExpired = showLoginExpiredDialog, - onDismissLogout = { showLogoutDialog = false }, - onDismissLoginOutdated = { showLoginOutdatedDialog = null }, - onDismissLoginExpired = { showLoginExpiredDialog = false }, - onLogout = onLogout, - onLogin = onLogin, - ), - messageState = MessageInteractionState( - messageOptionsParams = messageOptionsParams, - emoteInfoEmotes = emoteInfoEmotes, - userPopupParams = userPopupParams, - inputSheetState = inputSheetState, - onDismissMessageOptions = { messageOptionsParams = null }, - onDismissEmoteInfo = { emoteInfoEmotes = null }, - onDismissUserPopup = { userPopupParams = null }, - onOpenChannel = onOpenChannel, - onReportChannel = onReportChannel, - onOpenUrl = onOpenUrl, - ), + dialogViewModel = dialogViewModel, + activeChannel = activeChannel, + roomStateChannel = inputState.activeChannel, + inputSheetState = inputSheetState, snackbarHostState = snackbarHostState, + onAddChannel = { + channelManagementViewModel.addChannel(it) + dialogViewModel.dismissAddChannel() + }, + onLogout = onLogout, + onLogin = onLogin, + onOpenChannel = onOpenChannel, + onReportChannel = onReportChannel, + onOpenUrl = onOpenUrl, ) // External hosting upload disclaimer dialog - if (pendingUploadAction != null) { + if (dialogState.pendingUploadAction != null) { val uploadHost = remember { runCatching { java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host }.getOrElse { "" } } AlertDialog( - onDismissRequest = { pendingUploadAction = null }, + onDismissRequest = { dialogViewModel.setPendingUploadAction(null) }, title = { Text(stringResource(R.string.nuuls_upload_title)) }, text = { Text(stringResource(R.string.external_upload_disclaimer, uploadHost)) }, confirmButton = { TextButton( onClick = { preferenceStore.hasExternalHostingAcknowledged = true - val action = pendingUploadAction - pendingUploadAction = null + val action = dialogState.pendingUploadAction + dialogViewModel.setPendingUploadAction(null) action?.invoke() } ) { @@ -441,7 +369,7 @@ fun MainScreen( } }, dismissButton = { - TextButton(onClick = { pendingUploadAction = null }) { + TextButton(onClick = { dialogViewModel.setPendingUploadAction(null) }) { Text(stringResource(R.string.dialog_cancel)) } } @@ -449,10 +377,10 @@ fun MainScreen( } // New Whisper dialog - if (showNewWhisperDialog) { + if (dialogState.showNewWhisper) { var whisperUsername by remember { mutableStateOf("") } AlertDialog( - onDismissRequest = { showNewWhisperDialog = false }, + onDismissRequest = dialogViewModel::dismissNewWhisper, title = { Text(stringResource(R.string.whisper_new_dialog_title)) }, text = { OutlinedTextField( @@ -468,7 +396,7 @@ fun MainScreen( val username = whisperUsername.trim() if (username.isNotBlank()) { chatInputViewModel.setWhisperTarget(UserName(username)) - showNewWhisperDialog = false + dialogViewModel.dismissNewWhisper() } } ) { @@ -476,7 +404,7 @@ fun MainScreen( } }, dismissButton = { - TextButton(onClick = { showNewWhisperDialog = false }) { + TextButton(onClick = dialogViewModel::dismissNewWhisper) { Text(stringResource(R.string.dialog_cancel)) } } @@ -486,6 +414,30 @@ fun MainScreen( val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() + val gestureInputHidden by mainScreenViewModel.gestureInputHidden.collectAsStateWithLifecycle() + val gestureToolbarHidden by mainScreenViewModel.gestureToolbarHidden.collectAsStateWithLifecycle() + val effectiveShowInput = showInputState && !gestureInputHidden + val effectiveShowAppBar = showAppBar && !gestureToolbarHidden + + 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 overscrollReveal = remember { + overscrollRevealConnection( + frameThreshold = 15, + onReveal = { mainScreenViewModel.setGestureInputHidden(false) }, + ) + } + val chatScrollModifier = Modifier + .nestedScroll(toolbarTracker) + .nestedScroll(overscrollReveal) + + val swipeDownThresholdPx = with(density) { 56.dp.toPx() } // Hide/show system bars when fullscreen toggles val window = (context as? Activity)?.window @@ -514,7 +466,9 @@ fun MainScreen( pageCount = { pagerState.channels.size } ) var inputHeightPx by remember { mutableIntStateOf(0) } + if (!effectiveShowInput) inputHeightPx = 0 val inputHeightDp = with(density) { inputHeightPx.toDp() } + // scaffoldBottomContentPadding removed — input bar rendered outside Scaffold // Clear focus when keyboard fully reaches the bottom, but not when // switching to the emote menu. Prevents keyboard from reopening when @@ -550,6 +504,13 @@ fun MainScreen( } } + // Pager swipe reveals toolbar + LaunchedEffect(composePagerState.isScrollInProgress) { + if (composePagerState.isScrollInProgress) { + mainScreenViewModel.setGestureToolbarHidden(false) + } + } + Box(modifier = Modifier .fillMaxSize() .then(if (!isFullscreen && !isInPipMode) Modifier.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) else Modifier) @@ -565,87 +526,90 @@ fun MainScreen( // 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 totalMenuHeight = targetMenuHeight + navBarHeightDp // Shared scaffold bottom padding calculation - val hasDialogWithInput = showAddChannelDialog || showRoomStateDialog || showManageChannelsDialog || showNewWhisperDialog + val hasDialogWithInput = dialogState.showAddChannel || dialogState.showRoomState || 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 = { - Column(modifier = Modifier.fillMaxWidth()) { - if (showInputState) { - ChatInputLayout( - textFieldState = chatInputViewModel.textFieldState, - inputState = inputState.inputState, - enabled = inputState.enabled, - canSend = inputState.canSend, - showReplyOverlay = inputState.showReplyOverlay, - replyName = inputState.replyName, - isEmoteMenuOpen = inputState.isEmoteMenuOpen, - helperText = inputState.helperText, - isUploading = isUploading, - isFullscreen = isFullscreen, - isModerator = userStateRepository.isModeratorInChannel(inputState.activeChannel), - isStreamActive = currentStream != null, - hasStreamData = hasStreamData, - onSend = chatInputViewModel::sendMessage, - onLastMessageClick = chatInputViewModel::getLastMessage, - onEmoteClick = { - if (!inputState.isEmoteMenuOpen) { - keyboardController?.hide() - chatInputViewModel.setEmoteMenuOpen(true) - } else { - keyboardController?.show() - } - }, - showWhisperOverlay = inputState.showWhisperOverlay, - whisperTarget = inputState.whisperTarget, - onWhisperDismiss = { - chatInputViewModel.setWhisperTarget(null) - }, - onReplyDismiss = { - chatInputViewModel.setReplying(false) - }, - onToggleFullscreen = mainScreenViewModel::toggleFullscreen, - onToggleInput = mainScreenViewModel::toggleInput, - onToggleStream = { - activeChannel?.let { streamViewModel.toggleStream(it) } - }, - onChangeRoomState = { showRoomStateDialog = true }, - onNewWhisper = if (inputState.isWhisperTabActive) {{ showNewWhisperDialog = true }} else null, - showQuickActions = !isSheetOpen, - modifier = Modifier.onGloballyPositioned { coordinates -> - inputHeightPx = coordinates.size.height - } - ) - } - - // Sticky helper text + nav bar spacer when input is hidden - if (!showInputState) { - val helperText = inputState.helperText - if (!helperText.isNullOrEmpty()) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = helperText, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier - .navigationBarsPadding() - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) - .basicMarquee(), - textAlign = TextAlign.Start - ) - } + ChatBottomBar( + showInput = effectiveShowInput, + textFieldState = chatInputViewModel.textFieldState, + inputState = inputState, + isUploading = dialogState.isUploading, + isLoading = tabState.loading, + isFullscreen = isFullscreen, + isModerator = userStateRepository.isModeratorInChannel(inputState.activeChannel), + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, + isSheetOpen = isSheetOpen, + onSend = chatInputViewModel::sendMessage, + onLastMessageClick = chatInputViewModel::getLastMessage, + onEmoteClick = { + if (!inputState.isEmoteMenuOpen) { + keyboardController?.hide() + chatInputViewModel.setEmoteMenuOpen(true) + } else { + keyboardController?.show() } + }, + onWhisperDismiss = { chatInputViewModel.setWhisperTarget(null) }, + onReplyDismiss = { chatInputViewModel.setReplying(false) }, + onToggleFullscreen = mainScreenViewModel::toggleFullscreen, + onToggleInput = mainScreenViewModel::toggleInput, + onToggleStream = { activeChannel?.let { streamViewModel.toggleStream(it) } }, + onChangeRoomState = dialogViewModel::showRoomState, + onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, + onInputHeightChanged = { inputHeightPx = it }, + ) + } + + // Shared toolbar action handler + val handleToolbarAction: (ToolbarAction) -> Unit = { action -> + when (action) { + is ToolbarAction.SelectTab -> { + channelTabViewModel.selectTab(action.index) + scope.launch { composePagerState.scrollToPage(action.index) } + } + is ToolbarAction.LongClickTab -> { + channelTabViewModel.selectTab(action.index) + scope.launch { composePagerState.scrollToPage(action.index) } + } + ToolbarAction.AddChannel -> dialogViewModel.showAddChannel() + ToolbarAction.OpenMentions -> sheetNavigationViewModel.openMentions() + 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) } + onReloadEmotes() + } + ToolbarAction.Reconnect -> { + channelManagementViewModel.reconnect() + onReconnect() } + ToolbarAction.ClearChat -> dialogViewModel.showClearChat() + ToolbarAction.ToggleStream -> activeChannel?.let { streamViewModel.toggleStream(it) } + ToolbarAction.OpenSettings -> onNavigateToSettings() } } @@ -654,55 +618,17 @@ fun MainScreen( FloatingToolbar( tabState = tabState, composePagerState = composePagerState, - showAppBar = showAppBar && visible, + showAppBar = effectiveShowAppBar && visible, isFullscreen = isFullscreen, isLoggedIn = isLoggedIn, currentStream = currentStream, hasStreamData = hasStreamData, streamHeightDp = streamHeightDp, totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onTabSelected = { index -> - channelTabViewModel.selectTab(index) - scope.launch { composePagerState.scrollToPage(index) } - }, - onTabLongClick = { index -> - channelTabViewModel.selectTab(index) - scope.launch { composePagerState.scrollToPage(index) } - }, - onAddChannel = { showAddChannelDialog = true }, - onOpenMentions = { sheetNavigationViewModel.openMentions() }, - onLogin = onLogin, - onRelogin = onRelogin, - onLogout = { showLogoutDialog = true }, - onManageChannels = { showManageChannelsDialog = true }, - onOpenChannel = onOpenChannel, - onRemoveChannel = { showRemoveChannelDialog = true }, - onReportChannel = onReportChannel, - onBlockChannel = { showBlockChannelDialog = true }, - onCaptureImage = { - if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else pendingUploadAction = onCaptureImage - }, - onCaptureVideo = { - if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else pendingUploadAction = onCaptureVideo - }, - onChooseMedia = { - if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else pendingUploadAction = onChooseMedia - }, - onReloadEmotes = { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() - }, - onReconnect = { - channelManagementViewModel.reconnect() - onReconnect() - }, - onClearChat = { showClearChatDialog = true }, - onToggleStream = { - activeChannel?.let { streamViewModel.toggleStream(it) } - }, - onOpenSettings = onNavigateToSettings, + onAction = handleToolbarAction, endAligned = endAligned, showTabs = showTabs, + streamToolbarAlpha = if (hasVisibleStream || isKeyboardClosingWithStream || wasKeyboardClosingWithStream) streamToolbarAlpha.value else 1f, modifier = toolbarModifier, ) } @@ -740,6 +666,7 @@ fun MainScreen( // Shared scaffold content (pager) val scaffoldContent: @Composable (PaddingValues, Dp) -> Unit = { paddingValues, chatTopPadding -> + // Input bar is rendered outside Scaffold, so calculateBottomPadding() is 0 here Box(modifier = Modifier.fillMaxSize()) { val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() DankBackground(visible = showFullScreenLoading) @@ -754,7 +681,7 @@ fun MainScreen( if (tabState.tabs.isEmpty() && !tabState.loading) { EmptyStateContent( isLoggedIn = isLoggedIn, - onAddChannel = { showAddChannelDialog = true }, + onAddChannel = dialogViewModel::showAddChannel, onLogin = onLogin, onToggleAppBar = mainScreenViewModel::toggleAppBar, modifier = Modifier.padding(paddingValues) @@ -765,78 +692,121 @@ fun MainScreen( .fillMaxSize() .padding(top = paddingValues.calculateTopPadding()) ) { - HorizontalPager( - state = composePagerState, - modifier = Modifier.fillMaxSize(), - 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, _ -> - userPopupParams = UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - }, - onMessageLongClick = { messageId, channel, fullMessage -> - messageOptionsParams = MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - }, - onEmoteClick = { emotes -> - emoteInfoEmotes = emotes - }, - onReplyClick = { replyMessageId, replyName -> - sheetNavigationViewModel.openReplies(replyMessageId, replyName) - }, - showInput = showInputState, - isFullscreen = isFullscreen, - hasHelperText = !inputState.helperText.isNullOrEmpty(), - onRecover = { - if (isFullscreen) mainScreenViewModel.toggleFullscreen() - if (!showInputState) mainScreenViewModel.toggleInput() - }, - contentPadding = PaddingValues( - top = chatTopPadding + 56.dp, - bottom = paddingValues.calculateBottomPadding() - ), - onScrollDirectionChanged = { } - ) + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = composePagerState, + modifier = Modifier.fillMaxSize(), + 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, _ -> + dialogViewModel.showUserPopup(UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + )) + }, + onMessageLongClick = { messageId, channel, fullMessage -> + dialogViewModel.showMessageOptions(MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + )) + }, + onEmoteClick = { emotes -> + dialogViewModel.showEmoteInfo(emotes) + }, + onReplyClick = { replyMessageId, replyName -> + sheetNavigationViewModel.openReplies(replyMessageId, replyName) + }, + showInput = effectiveShowInput, + isFullscreen = isFullscreen, + hasHelperText = !inputState.helperText.isNullOrEmpty(), + onRecover = { + if (isFullscreen) mainScreenViewModel.toggleFullscreen() + if (!showInputState) mainScreenViewModel.toggleInput() + mainScreenViewModel.resetGestureState() + }, + contentPadding = PaddingValues( + top = chatTopPadding + 56.dp, + bottom = paddingValues.calculateBottomPadding() + inputHeightDp + when { + !effectiveShowInput && !isFullscreen -> max(navBarHeightDp, roundedCornerBottomPadding) + !effectiveShowInput -> roundedCornerBottomPadding + else -> 0.dp + } + ), + scrollModifier = chatScrollModifier, + onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, + onScrollDirectionChanged = { } + ) + } } + + // Edge gesture guards — consume touch to prevent pager swipes near screen edges. + // Uses physical left/right (not logical start/end) since system gesture + // insets are always physical regardless of layout direction. + val systemGestureInsets = WindowInsets.systemGestures + val edgeGuardModifier = Modifier + .fillMaxHeight() + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + down.consume() + do { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + } + } + + // Left edge guard + Box( + modifier = Modifier + .align(AbsoluteAlignment.CenterLeft) + .width(with(density) { systemGestureInsets.getLeft(density, layoutDirection).toDp() }) + .then(edgeGuardModifier) + ) + // Right edge guard + Box( + modifier = Modifier + .align(AbsoluteAlignment.CenterRight) + .width(with(density) { systemGestureInsets.getRight(density, layoutDirection).toDp() }) + .then(edgeGuardModifier) + ) } } } - - // Fullscreen Overlay Sheets - inside Scaffold content for edge-to-edge - FullScreenSheetOverlay( - sheetState = fullScreenSheetState, - isLoggedIn = isLoggedIn, - mentionViewModel = mentionViewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onDismiss = sheetNavigationViewModel::closeFullScreenSheet, - onDismissReplies = { - sheetNavigationViewModel.closeFullScreenSheet() - chatInputViewModel.setReplying(false) - }, - onUserClick = { userPopupParams = it }, - onMessageLongClick = { messageOptionsParams = it }, - onEmoteClick = { emoteInfoEmotes = it }, - onWhisperReply = chatInputViewModel::setWhisperTarget, - bottomContentPadding = paddingValues.calculateBottomPadding(), - ) } } + // Shared fullscreen sheet overlay + val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> + FullScreenSheetOverlay( + sheetState = fullScreenSheetState, + isLoggedIn = isLoggedIn, + mentionViewModel = mentionViewModel, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onDismissReplies = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = dialogViewModel::showUserPopup, + onMessageLongClick = dialogViewModel::showMessageOptions, + onEmoteClick = dialogViewModel::showEmoteInfo, + onWhisperReply = chatInputViewModel::setWhisperTarget, + bottomContentPadding = bottomPadding, + ) + } + if (useWideSplitLayout) { // --- Wide split layout: stream (left) | handle | chat (right) --- var splitFraction by remember { mutableFloatStateOf(0.6f) } @@ -879,8 +849,12 @@ fun MainScreen( .fillMaxSize() .padding(bottom = scaffoldBottomPadding), contentWindowInsets = WindowInsets(0), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - bottomBar = bottomBar, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = inputHeightDp), + ) + }, ) { paddingValues -> scaffoldContent(paddingValues, statusBarTop) } @@ -895,9 +869,36 @@ fun MainScreen( showTabsInSplit, ) + // Status bar scrim when toolbar is gesture-hidden + if (!isFullscreen && gestureToolbarHidden) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + ) + } + + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + + // Input bar - rendered after sheet overlay so it's on top + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ) + ) { + bottomBar() + } + emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - if (showInputState && isKeyboardVisible) { + if (effectiveShowInput && isKeyboardVisible) { SuggestionDropdown( suggestions = inputState.suggestions, onSuggestionClick = chatInputViewModel::applySuggestion, @@ -931,44 +932,87 @@ fun MainScreen( .fillMaxSize() .padding(bottom = scaffoldBottomPadding), contentWindowInsets = WindowInsets(0), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - bottomBar = bottomBar, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = inputHeightDp), + ) + }, ) { paddingValues -> - val chatTopPadding = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamHeightDp) + val chatTopPadding = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamHeightDp * streamToolbarAlpha.value) scaffoldContent(paddingValues, chatTopPadding) } } // end !isInPipMode // Stream View layer currentStream?.let { channel -> - StreamView( - channel = channel, - streamViewModel = streamViewModel, - isInPipMode = isInPipMode, - onClose = { - focusManager.clearFocus() - streamViewModel.closeStream() - }, - modifier = if (isInPipMode) { - Modifier.fillMaxSize() + val showStream = isInPipMode || !isKeyboardVisible || isLandscape + // Delay adding StreamView to composition to prevent WebView flash + var streamComposed by remember { mutableStateOf(false) } + LaunchedEffect(showStream) { + if (showStream) { + delay(100) + streamComposed = true } else { - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - streamHeightDp = with(density) { coordinates.size.height.toDp() } - } + streamComposed = false } - ) + } + if (showStream && streamComposed) { + StreamView( + channel = channel, + streamViewModel = streamViewModel, + isInPipMode = isInPipMode, + onClose = { + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = if (isInPipMode) { + Modifier.fillMaxSize() + } else { + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .graphicsLayer { alpha = streamToolbarAlpha.value } + .onGloballyPositioned { coordinates -> + streamHeightDp = with(density) { coordinates.size.height.toDp() } + } + } + ) + } + if (!showStream) { + streamHeightDp = 0.dp + } } - // Status bar scrim when stream is active (hidden in fullscreen and PiP) + // Fullscreen Overlay Sheets - above stream layer so they're not hidden + if (!isInPipMode) { + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + } + + // Input bar - rendered after sheet overlay so it's on top + if (!isInPipMode) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ) + ) { + bottomBar() + } + } + + // Status bar scrim when stream is active — fades with stream/toolbar if (currentStream != null && !isFullscreen && !isInPipMode) { Box( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .graphicsLayer { alpha = streamToolbarAlpha.value } .background(MaterialTheme.colorScheme.surface) ) } @@ -981,11 +1025,22 @@ fun MainScreen( true, ) + // Status bar scrim when toolbar is gesture-hidden — keeps status bar readable + if (!isInPipMode && !isFullscreen && gestureToolbarHidden) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + ) + } + // Emote Menu Layer - slides up/down independently of keyboard // Fast tween to match system keyboard animation speed if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - if (!isInPipMode && showInputState && isKeyboardVisible) { + if (!isInPipMode && effectiveShowInput && isKeyboardVisible) { SuggestionDropdown( suggestions = inputState.suggestions, onSuggestionClick = chatInputViewModel::applySuggestion, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index e248a0a60..abe40e955 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.main.compose import android.content.ClipData +import com.flxrs.dankchat.main.compose.dialogs.ConfirmationDialog import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHostState @@ -8,7 +9,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.ClipEntry @@ -20,78 +20,39 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams import com.flxrs.dankchat.chat.message.compose.MessageOptionsState import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel -import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.chat.user.compose.UserPopupDialog import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog +import com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -@Stable -data class ChannelDialogState( - val showAddChannel: Boolean, - val showManageChannels: Boolean, - val showRemoveChannel: Boolean, - val showBlockChannel: Boolean, - val showClearChat: Boolean, - val showRoomState: Boolean, - val activeChannel: UserName?, - val roomStateChannel: UserName?, - val onDismissAddChannel: () -> Unit, - val onDismissManageChannels: () -> Unit, - val onDismissRemoveChannel: () -> Unit, - val onDismissBlockChannel: () -> Unit, - val onDismissClearChat: () -> Unit, - val onDismissRoomState: () -> Unit, - val onAddChannel: (UserName) -> Unit, -) - -@Stable -data class AuthDialogState( - val showLogout: Boolean, - val showLoginOutdated: Boolean, - val showLoginExpired: Boolean, - val onDismissLogout: () -> Unit, - val onDismissLoginOutdated: () -> Unit, - val onDismissLoginExpired: () -> Unit, - val onLogout: () -> Unit, - val onLogin: () -> Unit, -) - -@Stable -data class MessageInteractionState( - val messageOptionsParams: MessageOptionsParams?, - val emoteInfoEmotes: List?, - val userPopupParams: UserPopupStateParams?, - val inputSheetState: InputSheetState, - val onDismissMessageOptions: () -> Unit, - val onDismissEmoteInfo: () -> Unit, - val onDismissUserPopup: () -> Unit, - val onOpenChannel: () -> Unit, - val onReportChannel: () -> Unit, - val onOpenUrl: (String) -> Unit, -) - @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreenDialogs( - channelState: ChannelDialogState, - authState: AuthDialogState, - messageState: MessageInteractionState, + dialogViewModel: DialogStateViewModel, + activeChannel: UserName?, + roomStateChannel: UserName?, + inputSheetState: InputSheetState, snackbarHostState: SnackbarHostState, + onAddChannel: (UserName) -> Unit, + onLogout: () -> Unit, + onLogin: () -> Unit, + onOpenChannel: () -> Unit, + onReportChannel: () -> Unit, + onOpenUrl: (String) -> Unit, ) { + val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() @@ -103,98 +64,66 @@ fun MainScreenDialogs( // region Channel dialogs - if (channelState.showAddChannel) { + if (dialogState.showAddChannel) { AddChannelDialog( - onDismiss = channelState.onDismissAddChannel, - onAddChannel = channelState.onAddChannel + onDismiss = dialogViewModel::dismissAddChannel, + onAddChannel = onAddChannel ) } - if (channelState.showManageChannels) { + if (dialogState.showManageChannels) { val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() ManageChannelsDialog( channels = channels, onApplyChanges = channelManagementViewModel::applyChanges, - onDismiss = channelState.onDismissManageChannels + onChannelSelected = channelManagementViewModel::selectChannel, + onDismiss = dialogViewModel::dismissManageChannels ) } - if (channelState.showRoomState && channelState.roomStateChannel != null) { + if (dialogState.showRoomState && roomStateChannel != null) { RoomStateDialog( - roomState = channelRepository.getRoomState(channelState.roomStateChannel), + roomState = channelRepository.getRoomState(roomStateChannel), onSendCommand = { command -> chatInputViewModel.trySendMessageOrCommand(command) }, - onDismiss = channelState.onDismissRoomState + onDismiss = dialogViewModel::dismissRoomState ) } - if (channelState.showRemoveChannel && channelState.activeChannel != null) { - AlertDialog( - onDismissRequest = channelState.onDismissRemoveChannel, - title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, - text = { Text(stringResource(R.string.confirm_channel_removal_message_named, channelState.activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.removeChannel(channelState.activeChannel) - channelState.onDismissRemoveChannel() - } - ) { - Text(stringResource(R.string.confirm_channel_removal_positive_button)) - } + if (dialogState.showRemoveChannel && activeChannel != null) { + ConfirmationDialog( + title = stringResource(R.string.confirm_channel_removal_question_named, activeChannel), + confirmText = stringResource(R.string.confirm_channel_removal_positive_button), + onConfirm = { + channelManagementViewModel.removeChannel(activeChannel) + dialogViewModel.dismissRemoveChannel() }, - dismissButton = { - TextButton(onClick = channelState.onDismissRemoveChannel) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = dialogViewModel::dismissRemoveChannel, ) } - if (channelState.showBlockChannel && channelState.activeChannel != null) { - AlertDialog( - onDismissRequest = channelState.onDismissBlockChannel, - title = { Text(stringResource(R.string.confirm_channel_block_title)) }, - text = { Text(stringResource(R.string.confirm_channel_block_message_named, channelState.activeChannel)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.blockChannel(channelState.activeChannel) - channelState.onDismissBlockChannel() - } - ) { - Text(stringResource(R.string.confirm_user_block_positive_button)) - } + if (dialogState.showBlockChannel && activeChannel != null) { + ConfirmationDialog( + title = stringResource(R.string.confirm_channel_block_question_named, activeChannel), + confirmText = stringResource(R.string.confirm_user_block_positive_button), + onConfirm = { + channelManagementViewModel.blockChannel(activeChannel) + dialogViewModel.dismissBlockChannel() }, - dismissButton = { - TextButton(onClick = channelState.onDismissBlockChannel) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = dialogViewModel::dismissBlockChannel, ) } - if (channelState.showClearChat && channelState.activeChannel != null) { - AlertDialog( - onDismissRequest = channelState.onDismissClearChat, - title = { Text(stringResource(R.string.clear_chat)) }, - text = { Text(stringResource(R.string.confirm_user_delete_message)) }, - confirmButton = { - TextButton( - onClick = { - channelManagementViewModel.clearChat(channelState.activeChannel) - channelState.onDismissClearChat() - } - ) { - Text(stringResource(R.string.dialog_ok)) - } + if (dialogState.showClearChat && activeChannel != null) { + ConfirmationDialog( + title = stringResource(R.string.confirm_clear_chat_question), + confirmText = stringResource(R.string.dialog_ok), + onConfirm = { + channelManagementViewModel.clearChat(activeChannel) + dialogViewModel.dismissClearChat() }, - dismissButton = { - TextButton(onClick = channelState.onDismissClearChat) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = dialogViewModel::dismissClearChat, ) } @@ -202,65 +131,54 @@ fun MainScreenDialogs( // region Auth dialogs - if (authState.showLogout) { - AlertDialog( - onDismissRequest = authState.onDismissLogout, - title = { Text(stringResource(R.string.confirm_logout_title)) }, - text = { Text(stringResource(R.string.confirm_logout_message)) }, - confirmButton = { - TextButton( - onClick = { - authState.onLogout() - authState.onDismissLogout() - } - ) { - Text(stringResource(R.string.confirm_logout_positive_button)) - } + if (dialogState.showLogout) { + ConfirmationDialog( + title = stringResource(R.string.confirm_logout_question), + confirmText = stringResource(R.string.confirm_logout_positive_button), + onConfirm = { + onLogout() + dialogViewModel.dismissLogout() }, - dismissButton = { - TextButton(onClick = authState.onDismissLogout) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = dialogViewModel::dismissLogout, ) } - if (authState.showLoginOutdated) { + if (dialogState.loginOutdated != null) { AlertDialog( - onDismissRequest = authState.onDismissLoginOutdated, + onDismissRequest = dialogViewModel::dismissLoginOutdated, title = { Text(stringResource(R.string.login_outdated_title)) }, text = { Text(stringResource(R.string.login_outdated_message)) }, confirmButton = { TextButton(onClick = { - authState.onDismissLoginOutdated() - authState.onLogin() + dialogViewModel.dismissLoginOutdated() + onLogin() }) { Text(stringResource(R.string.oauth_expired_login_again)) } }, dismissButton = { - TextButton(onClick = authState.onDismissLoginOutdated) { + TextButton(onClick = dialogViewModel::dismissLoginOutdated) { Text(stringResource(R.string.dialog_dismiss)) } } ) } - if (authState.showLoginExpired) { + if (dialogState.showLoginExpired) { AlertDialog( - onDismissRequest = authState.onDismissLoginExpired, + onDismissRequest = dialogViewModel::dismissLoginExpired, title = { Text(stringResource(R.string.oauth_expired_title)) }, text = { Text(stringResource(R.string.oauth_expired_message)) }, confirmButton = { TextButton(onClick = { - authState.onDismissLoginExpired() - authState.onLogin() + dialogViewModel.dismissLoginExpired() + onLogin() }) { Text(stringResource(R.string.oauth_expired_login_again)) } }, dismissButton = { - TextButton(onClick = authState.onDismissLoginExpired) { + TextButton(onClick = dialogViewModel::dismissLoginExpired) { Text(stringResource(R.string.dialog_dismiss)) } } @@ -271,7 +189,7 @@ fun MainScreenDialogs( // region Message interactions - messageState.messageOptionsParams?.let { params -> + dialogState.messageOptionsParams?.let { params -> val viewModel: MessageOptionsComposeViewModel = koinViewModel( key = params.messageId, parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } @@ -305,12 +223,12 @@ fun MainScreenDialogs( onTimeout = viewModel::timeoutUser, onBan = viewModel::banUser, onUnban = viewModel::unbanUser, - onDismiss = messageState.onDismissMessageOptions + onDismiss = dialogViewModel::dismissMessageOptions ) } } - messageState.emoteInfoEmotes?.let { emotes -> + dialogState.emoteInfoEmotes?.let { emotes -> val viewModel: EmoteInfoComposeViewModel = koinViewModel( key = emotes.joinToString { it.id }, parameters = { parametersOf(emotes) } @@ -319,12 +237,12 @@ fun MainScreenDialogs( items = viewModel.items, onUseEmote = { chatInputViewModel.insertText("$it ") }, onCopyEmote = { /* TODO: copy to clipboard */ }, - onOpenLink = { messageState.onOpenUrl(it) }, - onDismiss = messageState.onDismissEmoteInfo + onOpenLink = { onOpenUrl(it) }, + onDismiss = dialogViewModel::dismissEmoteInfo ) } - messageState.userPopupParams?.let { params -> + dialogState.userPopupParams?.let { params -> val viewModel: UserPopupComposeViewModel = koinViewModel( key = "${params.targetUserId}${params.channel?.value.orEmpty()}", parameters = { parametersOf(params) } @@ -335,7 +253,7 @@ fun MainScreenDialogs( badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }, onBlockUser = viewModel::blockUser, onUnblockUser = viewModel::unblockUser, - onDismiss = messageState.onDismissUserPopup, + onDismiss = dialogViewModel::dismissUserPopup, onMention = { name, displayName -> chatInputViewModel.mentionUser( user = UserName(name), @@ -345,18 +263,17 @@ fun MainScreenDialogs( onWhisper = { name -> chatInputViewModel.updateInputText("/w $name ") }, - onOpenChannel = { _ -> messageState.onOpenChannel() }, + onOpenChannel = { _ -> onOpenChannel() }, onReport = { _ -> - messageState.onReportChannel() + onReportChannel() } ) } - val inputSheet = messageState.inputSheetState - if (inputSheet is InputSheetState.MoreActions) { - com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet( - messageId = inputSheet.messageId, - fullMessage = inputSheet.fullMessage, + if (inputSheetState is InputSheetState.MoreActions) { + MoreActionsSheet( + messageId = inputSheetState.messageId, + fullMessage = inputSheetState.fullMessage, onCopyFullMessage = { scope.launch { clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt new file mode 100644 index 000000000..375afafaf --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -0,0 +1,125 @@ +package com.flxrs.dankchat.main.compose + +import android.content.res.Resources +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.main.MainActivity +import com.flxrs.dankchat.main.MainEvent +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.coroutines.launch + +@Composable +fun MainScreenEventHandler( + navController: NavController, + resources: Resources, + snackbarHostState: SnackbarHostState, + mainEventBus: MainEventBus, + dialogViewModel: DialogStateViewModel, + chatInputViewModel: ChatInputViewModel, + channelTabViewModel: ChannelTabViewModel, + channelManagementViewModel: ChannelManagementViewModel, + mainScreenViewModel: MainScreenViewModel, + preferenceStore: DankChatPreferenceStore, +) { + val context = LocalContext.current + + // 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) + 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.Long + ) + 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.Long) + } + is MainEvent.LoginValidated -> { + snackbarHostState.showSnackbar( + message = resources.getString(R.string.snackbar_login, event.username), + duration = SnackbarDuration.Short + ) + } + is MainEvent.LoginOutdated -> { + dialogViewModel.showLoginOutdated(event.username) + } + MainEvent.LoginTokenInvalid -> { + dialogViewModel.showLoginExpired() + } + MainEvent.LoginValidationFailed -> { + snackbarHostState.showSnackbar( + message = resources.getString(R.string.oauth_verify_failed), + duration = SnackbarDuration.Short + ) + } + is MainEvent.OpenChannel -> { + channelTabViewModel.selectTab( + preferenceStore.channels.indexOf(event.channel) + ) + (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) + } + else -> Unit + } + } + } + + // Handle Login Result + val navBackStackEntry = navController.currentBackStackEntryAsState().value + val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } + LaunchedEffect(loginSuccess) { + if (loginSuccess == true) { + channelManagementViewModel.reconnect() + mainScreenViewModel.reloadGlobalData() + navBackStackEntry?.savedStateHandle?.remove("login_success") + launch { + val name = preferenceStore.userName + val message = if (name != null) { + resources.getString(R.string.snackbar_login, name) + } else { + resources.getString(R.string.login) + } + snackbarHostState.showSnackbar(message) + } + } + } + + // Handle data loading errors + val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() + LaunchedEffect(loadingState) { + if (loadingState is GlobalLoadingState.Failed) { + val state = loadingState as GlobalLoadingState.Failed + launch { + snackbarHostState.showSnackbar( + message = state.message, + actionLabel = resources.getString(R.string.snackbar_retry), + duration = SnackbarDuration.Long + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index 0feb55fa1..39e6b744f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -46,6 +46,20 @@ class MainScreenViewModel( private val _showAppBar = MutableStateFlow(true) val showAppBar: StateFlow = _showAppBar.asStateFlow() + private val _gestureInputHidden = MutableStateFlow(false) + val gestureInputHidden: StateFlow = _gestureInputHidden.asStateFlow() + + private val _gestureToolbarHidden = MutableStateFlow(false) + val gestureToolbarHidden: StateFlow = _gestureToolbarHidden.asStateFlow() + + fun setGestureInputHidden(hidden: Boolean) { _gestureInputHidden.value = hidden } + fun setGestureToolbarHidden(hidden: Boolean) { _gestureToolbarHidden.value = hidden } + + fun resetGestureState() { + _gestureInputHidden.value = false + _gestureToolbarHidden.value = false + } + init { // Load global data once at startup channelDataCoordinator.loadGlobalData() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt index 8498ab264..3f01145e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -5,6 +5,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -16,7 +17,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -101,8 +101,7 @@ fun StreamView( } webView }, - update = { _ -> - // For subsequent opens: load URL while attached + update = { view -> streamViewModel.setStream(channel, webView) }, modifier = webViewModifier @@ -112,22 +111,24 @@ fun StreamView( } if (!isInPipMode) { - IconButton( - onClick = onClose, + Box( + contentAlignment = Alignment.Center, modifier = Modifier .align(Alignment.TopEnd) .statusBarsPadding() .padding(8.dp) - .size(36.dp) + .size(28.dp) .background( - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f), shape = CircleShape ) + .clickable(onClick = onClose) ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.dialog_dismiss), - tint = MaterialTheme.colorScheme.onSurface + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt index fa95b34b6..1b1582751 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt @@ -5,6 +5,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.repo.stream.StreamDataRepository import com.flxrs.dankchat.main.stream.StreamWebView import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore @@ -13,13 +14,16 @@ 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.flow.update +import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @KoinViewModel class StreamViewModel( application: Application, + private val chatRepository: ChatRepository, private val streamDataRepository: StreamDataRepository, private val streamsSettingsDataStore: StreamsSettingsDataStore, ) : AndroidViewModel(application) { @@ -34,6 +38,24 @@ class StreamViewModel( currentStream != null && pipEnabled }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val hasStreamData: StateFlow = combine( + chatRepository.activeChannel, + streamDataRepository.streamData + ) { activeChannel, streamData -> + activeChannel != null && streamData.any { it.channel == activeChannel } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + init { + viewModelScope.launch { + chatRepository.channels.collect { channels -> + if (channels != null) { + streamDataRepository.fetchStreamData(channels) + } + } + } + } + private var lastStreamedChannel: UserName? = null var hasWebViewBeenAttached: Boolean = false @@ -80,6 +102,7 @@ class StreamViewModel( } override fun onCleared() { + streamDataRepository.cancelStreamData() cachedWebView?.destroy() cachedWebView = null lastStreamedChannel = null diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ConfirmationDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ConfirmationDialog.kt new file mode 100644 index 000000000..00a454e4e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ConfirmationDialog.kt @@ -0,0 +1,58 @@ +package com.flxrs.dankchat.main.compose.dialogs + +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.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfirmationDialog( + title: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + dismissText: String = stringResource(R.string.dialog_cancel), +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + 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/main/compose/dialogs/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt index d1434e7b2..451ff4834 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt @@ -9,7 +9,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.AlertDialog +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -18,7 +19,6 @@ 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -47,6 +47,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState fun ManageChannelsDialog( channels: List, onApplyChanges: (List) -> Unit, + onChannelSelected: (UserName) -> Unit, onDismiss: () -> Unit, ) { var channelToDelete by remember { mutableStateOf(null) } @@ -96,6 +97,11 @@ fun ManageChannelsDialog( onDragStarted = { /* Optional haptic feedback here */ }, onDragStopped = { /* Optional haptic feedback here */ } ), + onNavigate = { + onApplyChanges(localChannels.toList()) + onChannelSelected(channelWithRename.channel) + onDismiss() + }, onEdit = { channelToEdit = channelWithRename }, onDelete = { channelToDelete = channelWithRename.channel } ) @@ -123,28 +129,17 @@ fun ManageChannelsDialog( } if (channelToDelete != null) { - AlertDialog( - onDismissRequest = { channelToDelete = null }, - title = { Text(stringResource(R.string.confirm_channel_removal_title)) }, - text = { Text(stringResource(R.string.confirm_channel_removal_message)) }, - confirmButton = { - TextButton( - onClick = { - val channel = channelToDelete - if (channel != null) { - localChannels.removeIf { it.channel == channel } - } - channelToDelete = null - } - ) { - Text(stringResource(R.string.confirm_channel_removal_positive_button)) + ConfirmationDialog( + title = stringResource(R.string.confirm_channel_removal_question), + confirmText = stringResource(R.string.confirm_channel_removal_positive_button), + onConfirm = { + val channel = channelToDelete + if (channel != null) { + localChannels.removeIf { it.channel == channel } } + channelToDelete = null }, - dismissButton = { - TextButton(onClick = { channelToDelete = null }) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = { channelToDelete = null }, ) } @@ -167,6 +162,7 @@ fun ManageChannelsDialog( private fun ChannelItem( channelWithRename: ChannelWithRename, modifier: Modifier = Modifier, // This modifier will carry the drag handle semantics + onNavigate: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit ) { @@ -199,6 +195,13 @@ private fun ChannelItem( .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), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt index 3d1d0738f..7718be177 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt @@ -163,46 +163,28 @@ fun MessageOptionsDialog( } if (showBanDialog) { - AlertDialog( - onDismissRequest = { showBanDialog = false }, - title = { Text(stringResource(R.string.confirm_user_ban_title)) }, - text = { Text(stringResource(R.string.confirm_user_ban_message)) }, - confirmButton = { - TextButton(onClick = { - onBan() - showBanDialog = false - onDismiss() - }) { - Text(stringResource(R.string.confirm_user_ban_positive_button)) - } + ConfirmationDialog( + title = stringResource(R.string.confirm_user_ban_question), + confirmText = stringResource(R.string.confirm_user_ban_positive_button), + onConfirm = { + onBan() + showBanDialog = false + onDismiss() }, - dismissButton = { - TextButton(onClick = { showBanDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = { showBanDialog = false }, ) } if (showDeleteDialog) { - AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text(stringResource(R.string.confirm_user_delete_title)) }, - text = { Text(stringResource(R.string.confirm_user_delete_message)) }, - confirmButton = { - TextButton(onClick = { - onDelete() - showDeleteDialog = false - onDismiss() - }) { - Text(stringResource(R.string.confirm_user_delete_positive_button)) - } + ConfirmationDialog( + title = stringResource(R.string.confirm_user_delete_question), + confirmText = stringResource(R.string.confirm_user_delete_positive_button), + onConfirm = { + onDelete() + showDeleteDialog = false + onDismiss() }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = { showDeleteDialog = false }, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt index 6b551f188..3a788c1d1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt @@ -60,7 +60,7 @@ fun RoomStateDialog( ) } - var inputValue by remember { mutableStateOf(defaultValue as String) } + var inputValue by remember(type) { mutableStateOf(defaultValue as String) } AlertDialog( onDismissRequest = { parameterDialog = null }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt index e10cee754..d3254cbfe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan 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.material3.ExperimentalMaterial3Api @@ -23,6 +24,7 @@ import androidx.compose.material3.PrimaryTabRow 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.Modifier @@ -55,6 +57,13 @@ fun EmoteMenu( initialPage = 0, pageCount = { tabItems.size } ) + 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(), @@ -83,6 +92,9 @@ fun EmoteMenu( } } + val navBarBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current) + val navBarBottomDp = with(LocalDensity.current) { navBarBottom.toDp() } + HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), @@ -90,7 +102,7 @@ fun EmoteMenu( ) { page -> val tab = tabItems[page] val items = tab.items - + if (tab.type == EmoteMenuTab.RECENT && items.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { DankBackground(visible = true) @@ -102,10 +114,10 @@ fun EmoteMenu( ) } } else { - val navBarBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current) - val navBarBottomDp = with(LocalDensity.current) { navBarBottom.toDp() } + val gridState = if (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 = 8.dp + navBarBottomDp), verticalArrangement = Arrangement.spacedBy(8.dp), 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 index 0973ab3ba..f435caad7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -7,10 +7,12 @@ 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.unit.Dp import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.layout.onGloballyPositioned @@ -102,6 +104,34 @@ fun Modifier.avoidRoundedCorners(fallback: PaddingValues): Modifier = composed { ) } +/** + * Returns the bottom padding needed to avoid rounded display corners. + * Useful for [contentPadding][androidx.compose.foundation.lazy.LazyColumn] where a modifier + * would shrink the scrollable area instead of adding inset space. + * + * 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 maxRadius = maxOf(bottomLeft?.radius ?: 0, bottomRight?.radius ?: 0) + if (maxRadius == 0) return fallback + + return with(density) { maxRadius.toDp() } +} + @RequiresApi(api = 31) private fun RoundedCorner.calculateTopPaddingForComponent( componentX: Int, 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..310db8aed 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 @@ -18,10 +18,30 @@ 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 } 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 725ee8da3..7bf654b2c 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 @@ -132,7 +132,7 @@ fun String.appendSpacesBetweenEmojiGroup(): Pair> { } // append a whitespace after the last emoji, if necessary - if (index < lastIndex) { + if (index <= lastIndex) { val end = substring(index..lastIndex) if (!end.first().isWhitespace()) { addedSpacesPositions += index diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index fda68e8a9..b295c3179 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -54,6 +54,7 @@ Вы ўвайшлі як %1$s Памылка ўваходу Скапіявана: %1$s + Загрузка завершана: %1$s Памылка пры загрузцы Памылка пры загрузцы: %1$s Паўтарыць @@ -403,4 +404,11 @@ Пераключыць радок уводу Пераключыць панэль праграмы Памылка: %s + Выйсці? + Выдаліць гэты канал? + Выдаліць канал \"%1$s\"? + Заблакаваць канал \"%1$s\"? + Забаніць гэтага карыстальніка? + Выдаліць гэтае паведамленне? + Ачысціць чат? diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 825f36345..716a1e20a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -51,6 +51,7 @@ 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 Reintentar @@ -293,4 +294,11 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Afegeix un nom i color personalitzat per a usuaris 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? diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 04d6d8189..30fa8f5ad 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -54,6 +54,7 @@ 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 Opakovat @@ -404,4 +405,11 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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? diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 861d29998..d9ec05e7f 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -54,6 +54,7 @@ Anmelden als %1$s Anmelden fehlgeschlagen Kopiert: %1$s + Upload abgeschlossen: %1$s Fehler beim Hochladen Fehler beim Hochladen: %1$s Wiederholen @@ -419,4 +420,11 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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? diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 011565ac0..fe3c5034e 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -51,6 +51,7 @@ Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s Retry @@ -230,4 +231,11 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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? diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 3314833e5..77d10be54 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -51,6 +51,7 @@ Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s Retry @@ -231,4 +232,11 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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? diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index f17c841f2..f2b6f9f1e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -54,6 +54,7 @@ Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s Retry @@ -412,4 +413,11 @@ 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? diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 491d9f57c..c6a7ed65c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -419,4 +419,11 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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? diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 26f15e525..d803f9329 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -53,6 +53,7 @@ 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 Yritä uudelleen @@ -257,4 +258,11 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Lisää mukautettu nimi ja väri käyttäjille 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? diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 1f2183cc4..7b8cd21fc 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -54,6 +54,7 @@ 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 Réessayer @@ -402,4 +403,11 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 ? diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 06344734e..baf757f7f 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -54,6 +54,7 @@ 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 Újrapróbálkozás @@ -397,4 +398,11 @@ 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? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1171c8351..9a68e0a5c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -53,6 +53,7 @@ Accedendo come %1$s Impossibile accedere Copiato: %1$s + Caricamento completato: %1$s Errore durante il caricamento Errore durante il caricamento: %1$s Riprova @@ -386,4 +387,11 @@ 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? diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 474be7517..629c01223 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -53,6 +53,7 @@ %1$sとしてログイン ログインに失敗しました コピーしました:%1$s + アップロード完了:%1$s アップロード中にエラー アップロード中にエラー:%1$s 再試行 @@ -384,4 +385,11 @@ アプリバーの切り替え エラー: %s + ログアウトしますか? + このチャンネルを削除しますか? + チャンネル「%1$s」を削除しますか? + チャンネル「%1$s」をブロックしますか? + このユーザーをBANしますか? + このメッセージを削除しますか? + チャットをクリアしますか? diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index b47dfd487..868d64279 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -423,4 +423,11 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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? diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 08841f1eb..72813348e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -54,6 +54,7 @@ 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 Tentar Novamente @@ -397,4 +398,11 @@ 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? diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 4b1097b33..efcd54e41 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -54,6 +54,7 @@ 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 Tentaa novamente @@ -388,4 +389,11 @@ 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? diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 649031bec..b60bb4a39 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -54,6 +54,7 @@ Вы вошли как %1$s Ошибка входа Скопировано: %1$s + Загрузка завершена: %1$s Ошибка при загрузке Ошибка при загрузке: %1$s Повторить @@ -408,4 +409,11 @@ Переключить строку ввода Переключить панель приложения Ошибка: %s + Выйти? + Удалить этот канал? + Удалить канал \"%1$s\"? + Заблокировать канал \"%1$s\"? + Забанить этого пользователя? + Удалить это сообщение? + Очистить чат? diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index cbc61d41d..1a6f86d11 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -46,6 +46,7 @@ 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 Pokušaj ponovo @@ -198,4 +199,11 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Vidljivost emotova nezavisnih servisa Prikaži/sakrij traku aplikacije Greška: %s + Odjaviti se? + Ukloniti ovaj kanal? + Ukloniti kanal \"%1$s\"? + Blokirati kanal \"%1$s\"? + Banovati ovog korisnika? + Obrisati ovu poruku? + Obrisati čet? diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 73c9cb6e3..1329e9e5f 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -419,4 +419,11 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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? diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index d4313e93d..0b941ce34 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -54,6 +54,7 @@ Ви увійшли під ім\'ям %1$s Помилка входу Скопійовано: %1$s + Завантаження завершено: %1$s Помилка при завантаженні Помилка при завантаженні: %1$s Повторити @@ -405,4 +406,11 @@ Перемкнути панель додатку Помилка: %s + Вийти? + Видалити цей канал? + Видалити канал \"%1$s\"? + Заблокувати канал \"%1$s\"? + Забанити цього користувача? + Видалити це повідомлення? + Очистити чат? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ee1893c8..a1069aa12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ No channels added Confirm logout Are you sure you want to logout? + Log out? Logout Upload media Take picture @@ -57,6 +58,7 @@ Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s Retry @@ -102,9 +104,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 @@ -149,12 +154,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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7e5102ae..eb0023b9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,20 +4,20 @@ coroutines = "1.10.2" serialization = "1.10.0" datetime = "0.7.1-0.6.x-compat" immutable = "0.4.0" -ktor = "3.4.0" -coil = "3.3.0" +ktor = "3.4.1" +coil = "3.4.0" okhttp = "5.3.2" -ksp = "2.3.5" +ksp = "2.3.6" koin = "4.1.1" koin-annotations = "2.3.1" about-libraries = "13.2.1" androidGradlePlugin = "8.13.2" androidDesugarLibs = "2.1.5" -androidxActivity = "1.12.4" +androidxActivity = "1.13.0" androidxBrowser = "1.9.0" androidxConstraintLayout = "2.2.1" -androidxCore = "1.17.0" +androidxCore = "1.18.0" androidxEmoji2 = "1.6.0" androidxExif = "1.4.2" androidxFragment = "1.8.9" @@ -29,10 +29,10 @@ androidxRecyclerview = "1.4.0" androidxViewpager2 = "1.1.0" androidxRoom = "2.8.4" androidxWebkit = "1.15.0" -androidxDataStore = "1.2.0" -compose = "1.10.3" +androidxDataStore = "1.2.1" +compose = "1.10.5" compose-icons = "1.7.8" -compose-materia3 = "1.5.0-alpha14" +compose-materia3 = "1.5.0-alpha15" compose-material3-adaptive = "1.2.0" compose-unstyled = "1.49.6" material = "1.13.0" @@ -44,7 +44,7 @@ processPhoenix = "3.0.0" colorPicker = "3.1.0" reorderable = "2.4.3" -junit = "6.0.2" +junit = "6.0.3" mockk = "1.14.9" [libraries] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 61285a659d17295f1de7c53e24fdf13ad755c379..d997cfc60f4cff0e7451d19d49a82fa986695d07 100644 GIT binary patch delta 39855 zcmXVXQ+TCK*K{%yXUE#HZQHhO+vc9wwrx+$i8*m5wl%T&&+~r&Ngv!-pWN44UDd0q zdi&(t$mh2PCnV6y+L8_uoB`iaN$a}!Vy7BP$w_57W_S6jHBPo!x>*~H3E@!NHJR5n zxF3}>CVFmQ;Faa4z^^SqupNL0u)AhC`5XDvqE|eW zxDYB9iI_{E3$_gIvlD|{AHj^enK;3z&B%)#(R@Fow?F81U63)Bn1oKuO$0f29&ygL zJVL(^sX6+&1hl4Dgs%DC0U0Cgo0V#?m&-9$knN2@%cv6E$i_opz66&ZXFVUQSt_o% zAt3X+x+`1B(&?H=gM?$C(o3aNMEAX%6UbKAyfDlj{4scw@2;a}sZX%!SpcbPZzYl~ z>@NoDW1zM}tqD?2l4%jOLgJtT#~Iz^TnYGaUaW8s`irY13k|dLDknw)4hH6w+!%zP zoWo3z>|22WGFM$!KvPE74{rt7hs(l?Uk7m+SjozYJG7AZA~TYS$B-k(FqX51pZ2+x zWoDwrCVtHlUaQAS%?>?Zcs`@`M)*S6$a-E5SkXYjm`9L>8EtTzxP%`iXPCgUJhF)LmcO8N zeCq?6sCOM!>?In*g-Nf^!FLX_tD>tdP}Qu&LbWx+5!Z5l7?X!!hk3jRFlKDb!=Jb4 z7y6)re6Y!QE1a;yXoZC*S$_|pT`pA*(6Wwg%;_Q+d*jw;i=|e$DQU=EcB-K+hg9=O z{1{BQsH*V!6t5tw;`ONRF!yo~+cF4p}|xHPE&)@e@Lv4qTL%3}vh4G|Gb$6%Eu zF`@mf2gOj$jYquFnvFCfb9%(9@mOC4N7VWF#;_-4Hr`(ikV(L)V=*hH^P3I<8RXOBnd0%J)*S^v*+L=*srT zh$IKKg?&n5H(Rho@`U^AyL=sN%WY)ZC9U)pfGVfaJpz+_n0|qnri_sF-g>-w^_4A;{;3 z2zTOH6bxZt8k`rB(XAAo>wufzcNZRTJSseFF{MmVV&4XVmKoPC0qRQJG-r9i z#yqN9hrZoA&Zp?DMIJLUtN3A!LZ89wr@`lge7butX>Q;1Yyi18b3#kDs|o$Q-f=a? zS;F_#_D1zk={}uf4ziZ+zjshKO^HC9-@G@n%RhXcLA%&TP#874IHEe;@#u!C3X@nY zaHpT0mAZ-N7)vR8Z|0maGSnM=QxJ8gamH0hLc#sW`>p;KU>wz515s9BDjB0eaqI1( z-&+*wV~o4?ha@KJ;U1zi`2(eKXkxc`NMkKxnz>GSlA0~7IHQ4KQWUPKD<}r@FOC_{ zQIDL`U!eq4@;?!9qWmvk%A6XHbxRY5BPh%#HKP`2>-jhY*TfF#gwLOR~f=$-qCq2V;*bz#LtA+nS@}dcA9S9exiGl z^t`RA_OgVRSg5O!GyJTc)4w-v(m~t)U{2ti*am#Q9`)B^wNC!pE9&ktf6^Cgs(3X9 znK~S~S}nNMh1+T6K>hr}(e9VlKKdt<1`D@~mE;aSB-I=?S;M$lD9`O$<99XzLG2F4 zg8`M+SrA_Cb-Bfo#>)U*nB@lBkUE&<;vN{rnAmuX<|-}ae2*aJG4k@$v%Rc;IM}_v z)wgICOxg ze%Zi6xg$romfi!Wy}i| zT8L+Xa*7}ZVYkJGkOKG>+S57jEDu7AiCi}B5m-HgeIInYmDQX8g6_Liajf_Dx@k^H zg*_C0VY^d-Ta|p6or>0LP}E$ZB{BKT?Up&p1Y|j7746nM)xXv!Tbpbo+eiB_F>?By zkhP*}9ZfjtUYuZUHP^ z>k3^hW#o2WXM~+rrPq9-S8e7APJzY^smW%tJr+s9W{Vi(i`b0pOOfxG`?0-rvo|Fu z#?Do52Z*#pPec0jqtd!y(#T zT|aPAx4<9ST0a)9E5r8l8Y4V0L4;bA_y?{VLNbAme_|R39vQ}m8Ix2Ay0~v%g}07A z86rGJYvG6Be5-4ml(;u`uZMOHPvEiySJ7Jm+^Hu3@33Ko4X$4i= z`nC#q;)J6=<0x<*q_BM)Def2(Xf%!7=adUcN5IX)Yw?1f*V=O+4!h3b)2;N{b>uUxh6KU zFO)rh!~d~HK-z83C*6m5@*(L@qJC@#9TY`${f#|l=ZoRMp7&rBx+gM))6PcXsA0v! z5eQ5U2zyP2%erLHmg=vZbWV&{KE@|FET}xun4QZ+j8GfNg+mtsW-R6kjeuGyVnU=K zBiAQ(?wz7!cz3VX?;-Xic;#aO&xN z-%mu;`sXgYc3{cqb|L1|aGf5UQDzrp1yHOB(HMD^+cpK9SIuM4E5cl5UM~-mybU^`JdHZ6$#~n_V)iQ+PAHacfSa#|SN;k`n%p(7#uf)Q> zlHE8+)PczLFiHEnu~aXa{g_hI94R&V(ZF;Wxh%tFIgmzT8f&bA)>us* zNA*!XoNoV-UPx|T<+mz&aZktvj-_f#meX&88P?CcuJY<%Iz z9~lFd)ITw&2kg3C!vE$_NDd!s8Mn5lu-na9mcBg$=B^ioWX6p8iLP&hule^!6j67i0mYIxNfR>X!CfH?G;y9Tl5)Q+4#bAL!BH~e%- zPkNQrOZIc5s*qXJ;9&h7_s5AJYt*oo2A?tQ*WAM`iaFre%Av|~a>uh&Pzl}s%(oCEd$G1=Km=P=^Tf==pM>*RcAANEI6hw9Vl<3&v zSEdp|TFrt)z!kqdUdibz_*TSj9WEbzlm+6Oym9gQk~vz@*OmO2cWHk$mMEtd*b*r7 z)drx#>)3)0d`ZeHYcf+1exTAWv9*UhjwA1*)%MKl5*IH}epmne{i8njH@p|m(oyy( zD{I8)8qH_SnUA6WFkaH2e4`UtYtt5I_@a_w%%E(o8bb0;@{8i`s?+C zGTz{xBP2eyi~$TfW3N(-R|c))j)dk$yggJDLo-Ur;A@or+w#Fuaqk zx#9j&Vv2ob(sZQpA{>3KU?H*Hf87&w!P(9lj3uA8s_0vlDtUVyIOvgPV@#~%%rVt@ zw6BW$7zKDvf#*ftc& z`H~cLVIoq;Ffl<@kX=47^^aG^#9GFmQE6-w$GApb zd5u1D4@*oJ9mk=`1HaHs?x`)mSd1G??$5*?JEn_`4Ckr-e%Lv8 zcB#IIsb5(CF>u-E29hB(7#I%{7?_gmcZlQ@Vk=OvyPfz5I?DDe+*)JmOOPpev2s!5 zIK)0cqIa_;UB%ily_J+%A|T>dKT_6--1`pFwIsG;*K~n)&@9E%hVLui3^)JrM*gqf zFR%tc@a|xLfAk1%?bH-MF}=Myt7mhS#jC-nv-iRC{I#EKf*^9;PGLcO7a!YiedEhe zeMZothG#o&RMk==LcAw{a;bg2&b7K%WTk+4=gLh#9dDO`(_v0oYCTZ|BCdJ7i!ms{ zB=J|Hn`Nc3mWiQn{&&-{ws!}kD9Sim;8}pt^2HC`x{Ay?Roy54c-d-cnHg{7D5K9z zv@o)c)kswkaHTdvQly_s^g+sDyCjBAbP1%W229JAba?|uqOL*t$|KD^5g3dLKn=Xb z9IW_k?k*)kVn>2Rqj3QejshvLqXQ*1NVJuhKbcUhCA`nKZE_RACNfT&L* zI$YUQJO#8X!-yd3ATPe6yf7LIrHOsIX=b_STgI2a#J8f~@@ll&;%8Kx5|0McAwYlI zNs3D#p)W1q4pJN-#V@~&`C6yx!RKxhy`Cpk?OS$q4dS1IV;hOu-vH(l)%`YjbxgI-26N1|9c;#^ zv+fX)nq-IF#F{VG3bBNiglftne*B||U<63~qoRGb*J2JI7MaAxT6Pdd&(djcek2<= zsBapXlGbq_5`*;^l;cX+-Yulze+duS0ywRjUgkT)#(DTchjKp+>*L;RCt;mZ0$n-k z8u*%CMZ{sj|raK-MZ8XXWWlW)mEyE%K ztogoO4IMeUy1H89tZs(Vig2oUO8UKwC9>3rBxqq_g|@NvW(7NtqQTVfAn$BnHFI4O zZ}Lgk1PBRc%zl^=?B=SeX?x|xi9m0-pMZ}xi`&b{XcL+s=~>u6(+ldBR)}&hKUL9P zVzKOnJ?rBrkSm1gfFcFtn7^rsiJ5L4iyp}T`Y6l7WI}Urs8CuV<`%O12R%B%pvcko(+GnA~)yiUirPXJc=q1P_Rh-`zw_0r9tn*fwW6^V^o z)sML@p8m+~EowB=h?CjA+cr9xRfa$NmNxAalqixbE_s7ZUI!@;K82(r`=l&XyUwfq z!`lnA7>3ylx!48Wlgz>P-lb~w$b6a5+oec>)-d-M;nIHp7nFy0n24)&YO=>S0Z(Yp zO+c<;-(@g9FLsB2vu7RO!0A0{9UTU@frfuP7NgNzHlBvJ+!4@JygLpm{!|eyBtPp4 z3ymxmEb*`x(!{EU%z)C~WOHhb@J zfye(U_Ml~XTl7!d_W$<3ishk^C-c#ef)Ds^SywIDI{mDc9%P1WrBo{1tAiAHb$ zy&0#M4f-qfza8F84nQaWL~S&xNQzG|P>PQy{7o@?vfOk|$I}L{<>eEhVJ~=lJjGym zaWU54Hl1|b@B!8q_oTS?5{Gk{K&8em|M=<&KRlvg^r6cQJO zAu8~Z0eU3i>e=5qqP&$9=w_%xFYB^^LO7LLiRHA^|;S4F6ANMoL=;hZq->= zcSZ^2L)TMD99%?aFwzkZ2$=wMj1ihM{noHe=8-z}K}`R$`FI!B97|x@V}UbVRgO1y z5V37pra5X%7**FZt$6qSDskj3OMr8Dr{wqUpW?%Gj+WaI7IGC{QiQ_?6;BUws?iy9 zr?uCbV7fBv7#rQ!;fPu!Qv?;xMp~V;dS54b?$6MVY(Ljrd4$RVQ^uG=kJ!W`a>&%8 z{N;cW{8i2M^VZ4>D@LN0doB%ye<{pMpKn(ja8DnCG4Kjm?9foo%>}4B#jq zqVJ5aYS;aOeS$JPxW(!)UQWD%y-oS6x&B_=UC=)Wuf_ZRPE9$VPrx&G65;!18!SF# z8JNxYs%6L)e=H6SdCNvIkz)F0yeP*PMcXA6ZE&C~|S^US~Pw2fuW)yo8&XHYgy&QKWjlOsY|OFcq}iu28r z#83E>BRjZsGq~O-)*9))zhWJIa`hY?aJ)2j4|v$nY39=H+-39&s0#Ldiy?@So(>2a zR{k?D8-7N01QN4s>pMqB|38Z$v%);7COMHI81xK@5d)h9j70z{1BQk+E)CK`H@l`b z>1|^8B4&1w`%ov;oh^(Z^jTxcA;Af+EMfV9qa=RBm`SstuEtDq=!)Y%g~~VWxT;-_Q6;X z_oe!AJ3ptQr}_)qdK#%}cRtT*3%K zE>9)EnWh)2ol4C@>6=M89Wntx8XnICocs*JfbX5Y`^LX36EK&NUMp1dkspMN`wbHR&eKLgSS?2O;0?>XODKO444mdhRf z4lUz}Wk$%=Dbhd}WWZ;M!Aq@^tg~dG9u`#FVA5G+iaqaX55onBmg`B8VttXe%0v9! z)2!wlh{C+f#(~QiCyFPbH_hBa85E*3DNR0Nq6T>-KgacFeg|M7G1=f5z2nXf>GusU z{SEjTW2bp5OX~@XR;$;VDvN>Wd}vF{A6jjHT95|&jUMh6r5KbbNfCQ8!vAKi~a{NIp-4h91Q0|o|0oZLW$ z@Xsk_2kB~}X#zJ#At;Bm$P3so&9iJ^0~2Trkh_N?Qoq5XE=n}tGr3AhP_Q~%43ugR z>iJ*l2%MQ3`q@`Q>S)^Mzs(cQZO_d+TC`&XRcq6-9{XA5`}a2entZ>RVRQt~8TmFC zO{qBYMlf97!9ojQ-y+ns*xPg-u2Eyp<;}7#0nwDvj5)ySJL%4vWUf<}(xqs3X*BMC zuVa1ZGCpTAk!bSgk~{Z^&4rin?ifHAg~h^%oP_<2hA z^XcLK@xD}z84HB>%@hXfcUEb{c@_iEY=Nd!7E{wbQNxWsmz@^Fp@MXXZG>J|3pEG; z4I;ee&RgnGmN_mbgc(k3NH63T71RG0PflRE{`iTpJLKlGdx$2cs~ z#8YxgR93!?Pa_MMS#63_z!EY`1#~L?P>D>GPxrHj;_*!73POA4irGJjAPSLK24yNF zjbf$m>Y4l`Sij`np_S{rQk5Ir%`!%c77r8E&Anwc=~E{OCD7bp8)m~882=)R17(F6 zObD&-rkQTf<=k@Axu-{*1E#|&3#Jo+7?(=!T7Vwi##NR!xIJTeU{nR^c*UTl{I`83?m6Z#KF(`VcUkH02b)Y)4W%iXpCZe8&hQ%M_lTq3z3t~J&{mi=D-jX*b}n-W`RIpVQMDh z@!aALf&*Y#s!Ucb!7OQ(|JcqI!&O5v?qFBIfoQtNH(62KRLU$};@N$4wJCH+acP-o zZs3E@s(_cicL$IhaggsA{r;O`X6=&A)PucscLa{3d{<@}Ycbl*4MLX3Oh@q#PTRX? zK_mx>oFh4bh`WCU+K&<-t>f8i4K(g7XeJcjV2~LQp9bd_!fy&>438B;{iOHo=>fL8 zHUH)HOTFOnsSDZ$&-hPcTYIv>=V?%%BV|hoGD%R}-kh{wrM`o>N{)}Jl zdZ1P13p<^gUJY^wDb`)}x$+D9p?1SZ6qB5ZKSBI%SI zHb+Y1-B@PDFQ!I+*?GP@Hh|YfAn1Q4`~gZZo`_87mM9sM6AP&b z*s=0$xQNUsHdW%(JSmxvlMke+Y~=NLf7hFU4ew8I@JXm1Qjk zUp67_=$uQ-Q68@wg+JwRa}lRcv(lfLQ?$;9N_SKYSql6k7Gs-fEuPz}(5lhBn@@Yn zLw!L{&LdsFF=h*OoMv$#-8D&{?UE=Uz|4*kU**U7oC+NytdL1gI|*{M=COpy&=5## zLsvg;tf?Emq)D6lL*AsM1Yj4wA#2B0u%qpgk<*Ovv*T}?YKjXn1&mG=QH>h-CAo-c zge6B-8IRB1uSA(RlBe#`iGt?#I5=}2vb?*rqj(2???JkzS4&!ayf>Os!)x@a5jm;= z*k0(h(r(ELR|oD^azGYV)AC^pruZcBf<{iUv4YooTz)KM&)9zUT;w@P%wWH;2=4C- za4pwrs4_yDSf*iVv3my2=o!1&PwlI!zw^O@V`GI#6269RibKU8ImtT9$r2Gb2KjZ> zGm+LxJ8rVfO*3jTW(W6*`-ui~|w(Bq3D6>lIas>>v|P_BfK!>$rw&JI4Uk zbzAuareUX-UsUrAJrt%odUZL+jz0XeDn`YW21CxGW!{hMoQtEmmF?jP};#B*Pv*R!Z zxW%{;y$)-|J7&}p{gLIy8<6ij4$sJV-}~?hD=MsV*W@~!2_O4HUKhj9>r?>_2vkDz+5pwx|${|ob208d2 zxTyRewhZx#fEE{ZwmaPuL#?aM2QqLKX|i;i#? z%_<@1c$5G+c3(hEYS+BOe`J(aOWT^X0d8FrlZXz5sZNtX-2U}6qyQritVN{(o6MhbCh8Uo{X6V*; zCI+H%>Z8OjPDIkwlLI0f>t{!!{olryPV=7_|HvmpID}GqEU0Ul526k**RV*BhVHA- zC4rtOpUB?O#F+^?>VlXdTs=1DhNTD50kG@Twho=Ex9K};$f)HG_ zo;HdwX};3TWz{*5o71j>mBxT56XUMM$jp&oDKpG^54F4>cN_;a2sO5+9XR+CY+1T& zaf_o~I4A1QI;b!nLleQ|)=@Nqf4LeLBOP{%oHzK0Xg7%H6Gdu6u}n>QUUcdf4Z;gS z9%jHM9cg$^Fvi|W{3>*12;o8%9*|F}w48L4UEx-WmZD!wGRhxyuzveCXk%#j1YmVv zbbdBla;l8+#U4=Pr8y~RBi#xETz|&VQWvEmGdYf#y?aaAJs^|G@7;Xn5>#DX36ILjY`xqFFiDBSK!_ zSmrO)O?FnBtaWU<5)SF0%-@N95E(JkOS}-3HQw0_((7^3pcCz7Db#aH{Ztv}3c{F3 z9`wC};pA~_{8Nv%u8NQ)EV~Zn!|3B1S<9#=Hhz0=pi$PH6;ZSW1w{kSLFw~+8l1n2 z@c5=1c5B!zR?*TZWQ*zVSALXonhlVp=<@*W=WUf%JHU)yNGW5*(%xpj-C2&oI~JClY8V^7KfP>nN+>ti0V+ zaPvJbvYfidk?RUsBie4JyIZz@XzL!k#5pRJ&df8wTc)2yO!#{J`hK&*P+pUvdu3f{!mwdcnK{`y_r%EBVWa}+`47qTjA2|D3teK0ElsnzK2CN+rPqq z9%eLs7SjMK^wSB*F##!MXzvC!C!I7S?FT=JLUg*_2&Eyv8}F;-k6WnaW&a(w{92c; zyE2eo^_d!T>kPz~)8Bf*fAO2}lAtFTqw!Kr@q16OXJb`4uRAoS>1J_n0ViR;L{%XF z%LU-^5ZagUhsGmY9Eh)vIgC!<(4svy*7?;Zc31KO^g|VZa3FEXK{$-d)nwGxzBxrX$%|GWfsvxnAtX8#)L&Fe3H2f)4LMepvhiG7#&o?gx@u~Gf< zcvX1N6sW~u_p}wxi*Qw#pTc;8CqCKVAMRX6L#xWVjc zE4f~S`3&zbKj9!mk;{hL=Lg{@{cFlhaY50yE7rpZZ1CV2BlQG}W{`BgvclA_m2Gw` z47q{A??Iq$doUbf0|1h6f5EK&1^!+H<#!qQ_0I%_hJiw`vm${61Jn3F>M@f34;m4Z z73!El=F0sJ3qr{L>tyc9Bh7`S8~!%MotQ-k%F#51a0+TLQ4`)hd0gu?%W2DT704gR z0Y6+7VG!}Sua)~&X!iODEIhY-?=0Bf?v~rGzz}bgb{3|lvQNW_(rkn|VB@~C!#{pc zwG8F>Ip2ZM#78_L%R+|F%$?4l=Bfg(Y01C^%9Gx=5~P}EN*1rcjW6~hNghXAN?Z8# z(6k1G+RzJ&=OWLxkyW$FX6Y=McV-+ZhmJ=oGZvZL*~ba#+aal!6=!TF4ovQrD{fAS zERD$3@aH2GmE$02=lWoH^<3GH;k9AzXi7GY*VT-NpmkWgamq zxBv6<{lD_9mQ5b!{v$Su|I_+ukdTsT#4$jkF6L(D4sO=QcCHMjcE+x*>S~Z+|F(gF z#j0<*qN$^QZBm?4SpV=-q9Ig|ky?w_7>=eDz$iuQjt-g1)wsFylMJfBZiElIuG2d2_}13!Do&dKc9H z@wOaxB@rFfIS{MjMpl(p99dzbVVhOAl4VU+Z4sHgvB#r%mV=m{;-jL!cP7)LTq`L# z5oK^3X;qt4L(@`1;g`c`pd^FEkW|OsZEEOn!UKCID{~95?@*otOw&(QB)FyOx(|@N zT+gl+?wUo`OI&&P1K+)yj4SgIkoy$H5Bmy+697LVbv#u`;N zVAC|KaCIN>z47DhjXZc6Td%SI9Q=Og2O%mV)K2IOG*S@wvu-uhpzyj*7ii#bb(*yC zx-H<&@t~L7*@cl4ppH((zG)DH=rKXru1T>A6Kr;qRaY@|nz(Xc20aM2HJ~i`>SQ+> z`aO$XUHlkTfvLUz(8ZNe%I`GAZhM4R;C`P>G~V7~idPN$3_on4@na3Yzt~IhN509) zx-ZY%>^*ARzsM(>&J@#uI4GvD?R#*o$XEb?NTCH?-XsN>l&kg>xh93KfGRp59U0z&mBmzI?36&Oxw zhgbj?xh5uxdXCV|@^vhJIG}(NC=X4l>XE_G-i$jy5K}+YE&Pcey zExBLQ5&itH3SngF0tjFF17{oNLA?L)oDIED*(|}cvXhRFwu--aQQ@$~M*jHJrp1_6 zJXaB$O@u6ED?{{{Cgo$NK!~&pIN-USDZyTzWbwSVRp&paO*`w`5JQ79N7EnJEsuoc z!a`YO!j)3mFR)&L*>Na^Tog$;cUKmz!3JlIff}6f$zK2-2m<@aYUV}6>IoEeDZB=T z@5Lj_@QEByMx-N!&#h~)jVn=2kLdzs$NCF*OwdL_BVF>{`QBlHLES(CzZfwzLWuAz zF5Gf)G_3qR6|B7C`h?XW$t}4M=+m9sIJaaxmc5n85i9hDza1(%q%kCv2TPS5C+fjP+^*LHjt|vjQfB z*`RBRAhu&aR&Sm*wC51(E+f8k3DX;Icg%rhQhy=^sFx<@tKp+uD7yVMyPcfqZL=*) z$ud6>OJc+2mN_l1lU2-1DFDvL1J%^*(l|3@!-NwJD|&~2FWVzqp+`IpKH(FE57CbF z!ih(S&?tM)UG}>9ai|%Yd^f4jQ$462$mG1%*7TL_bIS38lw3@edk9l6^@{m7bAdqL z=>u8`;U6-}zzQU<|C_1K{*Tyj#f?CJDpr*CgMnyhFkw+;@e6`?23hR(e)e2%~Xk=5DYaZ}`sSzP$cjump=ohVk3j-md$Fw8pYUx&XTr)Q-Ct z#P!!wMz&l9?QsE-*+Dw_cO;T83(`Kpuw7Ksm@kW8A91D_Hc7SIz)6DLbPKS)o=>kb93KaYu#6aDV#>|P)TfdSc2PB3 zEHV{eey)!ipL%}`r?S{n!vcF1i^fx<1zLQcSEIf>jFoj*RN5#&6Vbe+RJy44kzsgx zFr`n0k0Lh-Zlm4-4_*xi;}0$f_t&Ak=KZD?foPasbJIr^@y-{vFBQBTzq&++<+s!` z!Fxyl=L~vNDA#Y6XfE=3w)wFP8tGqUZyBR6L4La>^D|3)bS{C0w-yqOXI0NF&C{dv zTCU1F(_aYqoNgU4aCId&Y_b zqBo6j1L>*9xS<^&!#Ye6A&&i4p-5EId%sY3*qIJ-wng%gxK!1wnXE_y{dMa`$Zd zU8az`#zNr^UbR7_&BZ&5cLGjfo43l=J;R#j4mueY~^Wdyr9a#Vj4H>+79(ew9F^8y)U zfVzm9)Q|CBdB!bP zHJ+OvP6<^mr?H}ndMAbak1>lO5i+x?v=90Bg!f`^)8EKz!Q3^oo^mboGN1M{Up`j% zDZ!?VLwCEnJeO?^vGE-oU}sp;5Snc1fMwf+TnzDe+q6&qvd9E5nxJc?S(Es1^CrsQ zwM>`cBQEJ(g<4Ed9vw5#=8}2Ny{d;A?vd@ne-A$$E;=DX_zeU^Rd-k8D8+WXI0{8k zLeQhH*Y;M2byiVD_s^A?plT0C1F7qH>WnJh0`(ieJ9HHN#J}zrf=H$PY(0M6;Bgjr z^S+Q^JkE#g#gAaJ;{h3y@u5^mv6^wdBxveguBNt3mobrIkOD~S9M?&VGVFUPgjls} zSYvb+zhz6Nj14cNd^u9ME$#{vg~btue>p*5oQeZ#gkSWW_$Xf^cD;7#VKF#?DxrH} zan5G!6&Z`nQF2glWo}kpl0Mw{JR>EZ8N`-75lc~C=;5^dXQ1E)V9LOmjkD>23hwwQ z(`S|ZviG8@bBxHt3%;~HTNDDmcX#zJ*AdyJ7tfZjfZ$C%W*Z50eN-~wETOAW>s$pj zRHE_4P(fc3TpZ!5c*yA>mc3f5;8JR+xLFbFF;{dLg8s&wj!$**3A#O}!Fv<~-3$c- z!91soC^WUL0VI%6(*#h39lW89ZBe|+Fd-rgiMj(w8rti}_l%uJ`=84KSl?W`R^i|O z9$XyT_*WE$na}$;qhq<@^()6hkn}9j-fI9yqzGNlc?dUBvVjy?_i7G9A8|0K5XoYi z(v|4mWZd4#D%WDXN!b_Rl_V5a-C|9A^C4iWrH{w)AgAj^#IjXH#8MBYJElZG6^fgn zcW8+d=-zS5OHe$cjNtC9qm^Y#4Z9~JXeNK;VyUfi-IwW+DgV#LdXI;?_Ya&K3zrF` ziWC>Pmj!Nfq;d~u3SL9?0AcR(i@gncxM$Llx{ny0u6vk=@|TV`BqoYeXhzhhG{92t zBP~m*{QCxjK!B9{^d8w-g^V(4S4efF{;-dUE}M)mSUUA7cF9*z_o$rs12zjyikr`# z;@L1IM4akqoO0&f&=y&~gX4Vl;{P*$P%Wlf_crFD{pm0*x*B@47dR<6 zJBPr(1kY@pgXj4LCfUEVDw4o!jfCvt&~r(opbX#SaC4|wmYe5M&Q;D`F6;Kim7w9T z@9h!RVVskbO&yv(iPoHzOX(X6e#HebSGXF;XPL}+vaD~cp!*J3l-$>T z3x5R7DD_~Cmol0FNe7E1;1=o2p$1^s~UgDkj$b3M(I$)vBt?c-{$CbkmJ6+}fhH z20e!9LZ`g3GKESCpRA=CF#1JG3b}0cGccXem79Uw(8P)pRq+;Q#94Hh>XvQXe&mkq zSKWE`zfi4;D3Z@$aF_h9cjxTly`IoE;Oq&UktgUK{{RYDdxAJy6}v>!dFq`G^6+nV zEN;u9t1(*Mu^bX4dVdJXUFGF?Kv;%XGa(Ug*S$)nZNCeMeL?3(DzwK? zL{YY4+a;`y2&7)rkBF#wz<7a2{EuD^;G;oM{~l8b|6eFERf!R#3G0RX2jw%L)Ye>F z+KwBR3oB~ecrtAmMWmqvHF>awUc`(tqC|dqeho9xvuNi-AuPPk|5}*2W%+n*w5$1{rq+`IFX5 zjr#Uly#-xuhX5z?cvXj#&KXy^V{Mj>FT--yxy(SWm%tek;)~r60K|D|dVulS(vG`M_4MTb6oNSE0 z&xn#L9N)J;npM7ktR((G7o|VySCZR98h|^F0D-e|6Q1(L1(TU}#ZJ>~P;yg0JLl7C zPgQn;P9bD?>)OT6HSe&y#2jk? zZkP5h48Vt~e=1aBLjVEHkzbbxwEZ7YSFlN7*-YlRDBI%4W^@GL$85Q4X8?0CPkwa^ zEFt3i(*t=^qxStn>+|*?5tmLnRVaWey!I`J3Bh3WCBHdw{?{KRU!of z<+OqxfhtBS&gzwAsJ6@a^;Muj?+TZ~{Yfn+-K-!Zu;_$>ZFxo@tCh{`OrlLHt8pr18=;(PT3U#De8>reXFgWXplR$= z`!ZV5e<0Hj11xBB2W>mol9NI2wKUU*{Dd0fl&pP>!hkG2tENeuY13o~SI@?NT*Hbh z^;_i|Tqn>n6WS*OP}ZMUur4)Bs@?86Ug^gTcoi$#xML@YzJ}MBrP;+CVg$-yJ7KA# z@O5~-AFst5SZ38!YGN7)G){tiIn~u}=sHi&e}&XEq4v9OVIhAD{cUPj<z@DOvY;`Ik^O)sjO<;EKq-fo!0jnd$eemn(a%e-I}fTt4W@U74{b9 zLiPkh;F0njigJ_~G*VksoiVXibQ#8;d~RlZPY~=G%4sid(%o`q*~Y1}?P?|y=fy^_ zf4v*G`tdH@HqVRO1u6-r3=i2d1utcEe_nSY72Q<)pqlsMeL*&6?oghY0e$>6A=|kFrn}bD)O@(|tI=Hlr*-9D~z3 z?_yoeM0dDL+f6Mck;(Q?!6yhS-ldyae;AAE1$zI7Dt8i>OndEq5})$pPJCKm^$Xg; z&C<_GnS-VBH~oGJ?jlf&u5e4mVaB4!*s59<`?Qn~1@>o?x7m zNarmOc|qA!l;`BsSpu8kaf2a-$ zzT{p`rNsd}BGZ30t*GhE3ja?s>=@S5q!;$HayBpVaNJyv5wg0P_IQB zLtA=!wuXH8#w5`R5&4$1``g^mmY`#Koi5nl#rLWhxbG998#L9_%uo@cKNP4tX}h7| z$JDz)`oo8x2xLPO>uAVeZyi$ge^6Stv?N=OP;%Tk@?J|7Z-NkoLYti(Lgg9R658s# zhNPG!lPHuQKX$yuhoAAf;-e#gpUYD|hF>r`(gMRwU+oy+!!OxK6i?*ClL0*79`rZ# zx??xFzbo~S4qD08)~-?T2i_(O-9|mhhm|QoQeIZvRV#|Kbl{)xXFvXkf4>MUcfpW0 zqRBydZ`<@TE1znn+FhD?{1n~R+p}pm+t)>1Q`Q&PQS0CFbQS)Ff4Gg$h9O(NOvc-> zX+#=#vf2C>o{?~QR^Zf=S*+kVONr(XJ>w1d!iJq2rmY3fW6Y1|_+&!(gvRxKj1+Gg z+2Y63*<42J$Y%4lY(3nLe_vEgsvRfqz$H?J$1i4yO8($X`9tRfd8Td54$T@bcmYu* zi_9_MFCEWOwBEAhBg)V>nkJh85nw^+D3;QYCV8!)UOr!P+>T9E@DPIm0`i4dc3hEMSQws@r#U1^0HR$6V& ze`DFFPw*kLTVNy3^ z7G;2VcoemX&S9KVz|s+%F3{C9f<}Sca2`J*0{0`DNOX_jEP(>n#zt_SV6pXy?gN<9 z>`-KPha=4eT(slB*n{DNR4YUie_P-gLl6}TY8Ad;@f^Ymf1(Q7#%PPj<&xq*m|9g# zg88_(Xy6$%SQ@w@oY=K%80(vkpuPDBHjZL*qO)ljF9{z(*U}@16>!-h$iFIVL%b+` z3n}TAi$>9#kQxfOyi;@)u(P{>-4_4r9;3&QTbN z;8o#a*!MX~e`fQcoTV3QoH2+6&bSbD&bS!MoH2ycopB}3az@t$0f;e@^oT-UjeG?b zO^h=Ff@4$oFg6DFj^Nq~`nATPu6L+os2Rl#3CS78tB>N1@|+cpS}!V=Jc~J^ncsd? zU`IIfipbF_NgO+&zrD3%IwswSX@~ z_))+YV^UA6ClY*+d)!Z$bIqYTPwW6f)cKV}thiOHM?~aSV^4}!&w;VWBM-rIh$}7+ zesy;Ne_y{HYa_J2y;E+~75wHfzH=BqI0k?4M_dji_|sNTxT%h@yf^r`yK@0gM1sHS zbe1iaVv*g!U%PVdg02GyM-Jn+$8fQn4*s5#NAXw5x(oj-;NJxyiYuE(#Vmq9+%zn_ z1)=a9%?07(P!O{Zjfy#mS}|`}1n(P**vGioI4OUyAWm+RWf7^|Fh&i^r)HcK23T*w>`5(E)~;Cv!$ zC$;1WfSU+`TPb}PtHYyAiYEw{r-%sb$BaDR(T973m7 ze=KnD$a8l(ZTv{SqJq~@^I9*xoy9Y{wo9t@!&Z-s5?`5#bA z2M9B)4G&NY0012p002-+0|XQR2nYxOldU8Wl7SbK-C8YwyZu11qM-Q2s!$TP8>3=_ z!~~_lLk*<0CO$Q{yVLE`{mR|l8e-&!_%DnJ8cqBG{wU+LXpG{6FZa%znKN@{?)~=t z^H%^5uq^QI__$enV|1lGpwKZk47+En8Fm!Jo-b1`3e6yLh;cS-^+F=$g)XB*QVI8B zyjHzmt(guDjkh|4K%o_7%BCI9CxMknxt6P>h7 zFncJ6((+~KTKnBYvQrJy0t?&qovn7`MQ69UwcV(HciOFbv$MDVye?2~{ARS$k+R1E z`ljuBp_e`p$W>Nf3e5kV^fdE)hm?kr!1U%gw}f*j7BGYJ0{M)kRr{<>$Av#swT_aM z0u2`hiY}!GD&l$4BZ1}0StYAyp%O0PashLg=fuC zf-PY23uaz@#B90z2@5BbBX^v`X57gxG`dC>(eI9tz=t@WJx`*}v_t?~hLaxPYmE_wDvReU%yN z4Y^z{r7q-5>ZWdu#m+QN)lE*!Jz2s)+^jGtU6Fs@guV`PS)dIxlWnPLY?T>zTxJW* z7gs#%(|>=_TgxC+sLoiDD~%)a#+6J5@_}zLPv__JROK|tw+RRV(}$+_nr@6G0jG^G zlhR{uDS7tTw&au5uYCGbw`knawI2VDVOPN68V5`)x-z-T)}*@__65ZBLb~sGVRU@* z$Y320Vi-fPWda9d1rg^Rh<*T2O9u!+{qJ}90000ild*ywlLK8hf6ZEXd{ouF|NYJ^ zcXBg8NC+@2GD47SlL#te5HVp5BmoIahef=Zxk*N5iL(UaLe*-mt=nsDD{A|!wM}d7 zW^oct743rB+EriezP#>>-B+vTeb2dfl9^-z`rbc}Pr|+ToZs(ve%tvi=j2PTJ@y0< zoh#nUbobGtJ62t_f4IvC9WvwL#Z8Mt-HYoNhZ3>ANYqG267fJR5jHWNG^3`GGBMd} zqynK{Gju4GiKP}dbsN!?S--fiClE9G0uf2$ysni-c;)$kO|Ht}cW0te45WIEz;b+= z@t#QBG?S5d4@UdVWD09xd{x6a4XXlSvw!h59%3fFGm%M#f6R@MsL527NcJ@LB#m&? zY&@Ja`ufad<0kdF$NFkFB5{qJOl6lF{YGQdi1##Z>$=Fvox8brY2`h-PeadnMFBV~p%$w+#jaU#rWFL`O2 zPNg)R>5Nmue`-|5Gz|-_gR(4%nHEf1Vtf|F%c(-AnKX-O?o?13&1NbE*|tPT854@h z5sjPa#$7wwKxi)cbeco+n7sKj8ZBUQr4ze$v`#{61=<<3NT-G5FGOqAXfaa>*6f6j z#30739BRI{y;Ma@by`Aa!7AM_u7|1%tY*P!RLkTxf3L{E$CxUs+a{WIbHr{#1mQ~Bh1jaGuCbi(q; zF}(mpjsSZVT~JErQxmu;;$|9MnDYiT+>ub8w%+XCn8?J#8;)%+spg67)gJ3G7w^1O7y}KizBkf4A&z z_g9+@Jq`ZA`q+S+T@xGVH=-G{2HWA?SRrhtLdl4&pYmdE@Lsx0@_8&5wbkm)$)quW zh&=V!-e&R?QdRshMtvh zUxL5JjDao_D<#w0Y!5G*Jwg0A`if3Z(N~#7AmE{|GX+j7NOL#Xwd0XS-;^8R_3Hcu zot~%vf{cN{zDw5}sPoW^fA~ONLMfH<(sv{`b@W{%g;b_1WxID}b!*W${eAj@g#IC7 zZX#YF?cUcJ{7);YMKI5DSoX*C6REQQW?J#a@iqDxqM6OEv~qJ25}sZCI(RAM;urKw zoqkTg0=4S3sTy0KYZ_`j^c$!&5)Ye4wspg2puAQu{f=Iey86BJf92Mx)cHpV@+Y(; ziFmUe#+h1*dCnW<_Am5T$?e~eAQZQfS;gx=5WT997i1!bJFSnT0efgdl{kH z#t0mc2(RS20mV;q4%03xU(;z+rq0q(0@X+)p4w^-c+q5`e14Dx)0~N-v}7XDFfuQr zrQ(2x-8#EuY2%g^e^opT%%b8?L1wj=OIQa9E=BxEC#*>?PeTcVL9|KJQ5_&G=G5!u zGWsGk!!woEp~k)_iaak@DDyIUA9oa;WV%;HgH|uk<~gtu&xMSMct^sn3%oo}YWOLh zkKM26A&c5s z@nd{e2`}Ykx!$G_K;s&nYh{4tH6E^?B9KW3=LV^lMkey`a%ihBGqDP^Bju@U-CQ{3 zbNF28H0L3GS`y|LoP0jhlIp@%Vv53$W%BdG&-zi=7rJm29k_pyp`Q%l6R5u`04bR*?;=isa2O za9~qLF0B=)v0p^Rj7G+8>(#X;O&Us1#D`(!)oPH*dJq6@5B;E zmK9#!$-7G6iMz4cavR>uZ<4$H0S?M2nA#BQlZ)-ce=g%%MoZ#MMXtpDx)j?80|zH% zmpo|<34vB*QC@+7vZu$0s<1ZR>M-KOe2Y~-lD9vWiKZji$bPH9YVdHk&ZZ12i)^TH z!c6&POV?}kn|>ocV1WV>oy@W+JIh@#%x2i7Es;2sfu;^27_Q&2v3Xb9&V!qFG_P;l zaBx@We})|gH*ag-;N=(!SdMbsIw8qveu6yT zf8HS@elw%1SzJU4`|x0cIx9d*<99)18MK!b4L1{YXo>wEo$uuLVogg5rlQ9j_EPI? ze@P81yz?=>y9DUyaOM|5T8~~dnlQo|zpuEb7Ne>$nx5%#GkrLbJhU?sGZQj6Gt$`y z`2G^UkI~l50k8d#Vsg-{tDZvEVr>t9h(E0J`x$M|it1ugTW+$t2yUyTypKxs2g?YN zX-?FLb%l+p!h@x%vzcxyN_&FwRu?;de>w$Ar%?CmV#XiK0=vEZasGr(F8<^UH=_+( zJicxu-k&&RHnu5A+Re1lZG^zvfW{9aFvP|On4ZfI3^pDxdJ|zQGo`Amz*8jEO@%0r z0seQB){>{jt(iQ#&WJ`kBeLk^l8pJzI7%8hqQot=&sdnIJ^6O4}78;-~>u`6TsebXl#;qx>6tPC&ciMi3k&%xoN zMk?KEHAi0ls#P?84b#xoH&8L8e~fN(R}xA1j44ji$4EcVFUUZFW_DUS(cHPNwKZ4m zzo-tc`P;|=?d#9;@ON`3rDGQu?Pe-v^qA`-J*F&izi(w|Wt6zQ7+F4bhAvJ6{QQuA zr1KB>$4stWJ2wVac^Dn42V`3Y(lUz9E=F@-iM7-A)hxdjh1DYG1V=UjyWokv@ej zNR0`$#uS`zSYv4L=9x!Af6+`T(ywmYnnNL|u-%A5izso{Ch#Q)LgYm}`yu zn9g38$V9^`4uz5?Jj&mv4#fT89JIQ&kZQGJmq(y)^~8;MLKX+A)!pJ13&k0z2E`&5 z$$v9iE_M*V@MNz4hqiVgMI>UDA=D+3K%cpA>_#ipYsBMbG^Mn<&ic^AS-Ja`Ng!?D zM-$adB6-*&YIU(xf3|J9RF(zCbY^wljao7KP+mYZ09Bw9)zZlUNmNFYsqo}Hkd})T zx>zR8VOsrva6?VVc2%AJt&1j7<|XoAJvuPH`LVj1$X&yT^TjG%tP~d%^lUqOVYRR( zRwELmqNdp=H}@6^zD8W6iwnitT(e$yv7?D*K!)I%Ua^jzf0f?09$K(3*1cjQZP!JO z*d&YNNS8;nqA)Gu!7YhI8k^ndlQ~cwl%eLr#@VWiHW@WaqKE}jcKB~i;ZBMhF{zcb zOceVj+*^tcu}wPY_S`X$eGROfz75$&>Tid5$G^0Cv1}(#+wiv z$9na=8F^wpe`#-7Q{ZK<*r$u2*zYC7db?E0vaj&wdJ1f7Ghe2QPGKPXARoxhWf^Va z>99451w$e%Er-ojnUc5g@T?>00(R$BPraV#5xo*!CPrAS!9FO68ku;g*Gx88rHize zM;wwC0;U~dmY$~D%*C9Th)X>rJmj(N1g$!c>EhE|e^^=s@<}GmZh1RlSBjvW6e*ob zMY`bZun;6NM^1G+dY(D}MTa<6&C)za@f#WhSD#v`NZ zG);9|Wp|f3ZThz~@5pO9^E01)p)1~u;A{6$^2*C2u9JUFQRG}V?_g5A1zA_zz|`o6 zPhg?2fB&!%Ndrhlu;J!C?GU zMBD8ad*Pi;Qe!``(xI?F%0QD;Rg+87g;WX-1YRvot?TX9nA{ zw5+@)OO3~XK+v~Eleuy^Lx5>%2M`;Jsr$=aK(D^uN!L5$E z&hp*0!?bsZ_MO-&$7_e^vJ-?#g{D)G4$yq6qH0=8Lfk3;WQm-k_!Jtg(P#;=Mr%g_ ze`tL-6OED%Tsei;*+2lq0r74{O)?MH#e56ib@{gnmS~y}Lh3}$hidC`JcsbxUEW)M zd6wcsbVZiZ)=%3A^#}Lw?--&Z&PV8K*W*+d3_8k>b~?+i?aa~*<#mtH+jFD0VDvUQ zx+gbs2S(m0M}p;d0!2bl(Wwe;;gej?e?az;XIWmOe2=pB|#)Ba{s`xdJ}t z5Iy=RonUHm``nMx(@e+sS)WV3f0^k?kZ#hl^tEIB5uaB64P}a%BlJ9QCF-{ZN1wy^ zx3l!UW8?#x1_S=cryb1FPqXyvCfDHTLzw@qns1QvWoxqZhm{hr5}<#!Kr3C&f6LU{ zkFxZ4iF6o9|5QkRiR2sy^=a;Lu^MfVBrUv;@m3bFX*ZQfs1gNrqt7+MuAr~vU8Q7r)Al9|7*v6f38Z8^D-%FrANuy)4=Kn9G@(*z2Gqffw6 zR~N7=i4VSJOwE}Mu~wpFd4YUC$LEx6EgGQ*gB?TcFTW$pOOA7Omg`_Vmt||(B;RtD zc2{s9%V!5yYWEU!gU=ONUb$y*^m%+#YCgB4Qj>zXotH^7yAN8kk4Vq1f2-hCL%e#J zo10v6$zb51&o#vBv%IN-TeI9|t#FdO`1HAl`I0?8XR!Pz#=zH}uY!<1*(5X^zjWz8qN&fil9tAekd<1}nH{hp0)Lb%fs^e{2ub9_I(J)-ZqM z;1GYT-si4+j7Nw*l@~1QJ1h9{T(m?qQ!$Zmrv;;QKWSDBR6qS1-LKJ88hxJV6)khH?Jw;&wCc&%l9HmV~fPS6>8b!b?nTiI>`SqkvHE;b$pgB_jAtYM> zXP%1FQ7R?(*fd#_e{y(!-mpdwstM41l^P{?|D=UdCEPhm+oe8qnKLFKa3|5304&AO zt5jo6T+E{s%2zbsAX!y;=OUS3)VoSICuunn4T@a+zXVeaU^R+=Slf2K^A$}U}9Be<%Uk-L4?W%1%ER) zr~BMZ+8|9E3tn1%5LAZwTUq{2lc$2eH_Sg#8?}NFMt_;*-;VH02(r$V*lK^O^kB>U zwX7=3f46tx5dQ=FPp$4fXzj!%O-3xwaef(u5KL5>f7E@>rV`XDK8(B~N5maIS5ry7 zj0locy`*%UN5_cC$StXO=+qk3GF zjEGW13gCGHwe>?{dRADqRB)?IBWXLmND--9k`S}TurVEM&x$#B({d~9Osmg|d5ST= z3?ve_fA(O7Sdbs1WHjM+?id#SS>nuCg;;W-h%HhWDL{=Sf577U5z!WG9}?~Oz9iUwlFI6zaNb9H zy<sdh|b{tt$^5>6?@tdH5UdEG>653tN^=R!=k%3D=x1P(X8mhY$;-D z`I^oOaRr7mV-+dm>#99jadf;;ZFAHD?Akgz_Dy=fOWW|jY;wEX{ zf06=S*VfyL8pHDGu!)s7fcTDa&@q6LDF9T>Tp@0+9TM+6fdJn}{f>8tTWNr9QqNoI z9{J=K`G?{HB#W2$uj=_Szbc=CMTvTr2(PHYbGn$Rp0mXw^;{xq)U!owav-paP2v&- z-zj#>r-L1(>N(9(rk>@FD)n6ESSz1)e~S7k%^gMM?a}yz44!-+D)d~qmHFaj(qExj zEYnJH7!~kerMQQNRCbz<%rOO=0#PA(HKKPO5RHN0#lvnpiA*L%`A`z%X4yt4k_%+U z0+lt0^`ca!4|`&k%!hJ96H7I*43nCuapq<(M%0%W%kaAt#6-&|M#eB|au`d;e=t1A z7i7`0;bqG*f&TdNyJQPw@%4&g_FvQ~^Q(J|hy=F?&6K^4J(?#$9XT;QHX&`H1SA_s zDa$W$Cl1LjOWdl`UOz3Qc}RPUkoKy;@GEB2C497Ec3A?>vy?cIVk zh3f9`zjzOxUSb}GZ$8AI=7;_VP)i30OlKHPf*1e*?J|?0Bpj3Db1{Dld|PD||9?r_ zdz)sjmTt=!qm&K0u4%_$Wdsq$DA9Is~PQvmWHx*90ahvODJ7HTHo0|hxCL9~E zV;5wy$xMBu&q`$MruxDDaMBtKJHlgiZ>tq=J(jfTHO2FN*+ha1nE@+&6j3|X@1$%y z?WFp-y4_A^D2wZBnvZT?6OP;4>)&faDFnLQY&vFda1yq{VmE)?-_oD9;t9KDN7@=3 zw9_r^sf=eO5=)OVP^K_1?z?G1SjQq zYZcNB6ZM`7E2=jW%eSoK@^gZihw4g{qc(^Ds^n`y5W)OcD2Q2@Enf!*F$Z(y>ktKh zgPg0up#d1EQz)bB>A!;-mUm2!A*~CR8ew3m!mNJVJKKMfK<1-0w|KB@dnv-T1k7d26=Ka3!_<>wb0YzgH&80-0()iH=Zqs zB8#K2N~9f4Xv}4Z= z;zX>K-IIT)u9FciL9EL!ouV*@#;)tlxQVQ1pKW;qL9EYPcdEjo=~KeMX}pkDEM{kz zkt>;#{S7l_(3@E?!{Ma`*d~RBzH7(Z0yrIKC>;3~4;eU<+U5yQcawC$S(1>QID0~w z=(;H5*+~N%={Y;idtG}#?X#(+M_p|zNewn(b0vSea1QTypXDU7Y5Pq2!RlwqR8N&K z??6AT`mWT73h9 zbbo)JE5+OPVgm|?PMOxlQY6-LK10j7MV zogDNo>fi~+qUZ@tDQk4ZyYZd?-i7y)G{F@SPp8dmSiWU)&3GT)FY-RXOEPKCzz2(= z)U4N~)0UQL;6njiCPl<=#p9D=S*T!gC9i+LhlTD+CeTC$4SbZrbUd3eaG8PgCz#M) zSf_Fy!^f*|6|Sb0Z`?Os%DQ?+YDYf6i9iq**nb6tPyPUxenG>c<=mTc(;2z}U;4sVT zc)-Y@g+s=vJ7e}>{?6T*??3rcJeq&E<8H1sXY}PWaW9dy&4Rt1)uw*>wo|-JL3{`I z3zzTG8%3>7$@cZxX*<5rwsht82J7aVbeY7p#UDl z4;0EbZ`u%EW8y~&jpKwRJf`hxj|8wEKbDeq;8+rQcwNf%>iVQ|)$vXZ z)UlE==YPXXGexEsQ_a9{8L5obXKzlkkS=MMRO2Q`=^6Y!fZyTSNwY+;Xv{cEJSR8r zj|!^U#GmO7Iw|9(B2@A(()WLCuh5=?_^Y_**Z3P%b2H5;PB|w2&apvKF6~l(k2Um& zw=~R9@;~r$fPL_v#hRZlV{#+tzJDwDHg_H9h$VYG`5(MmiC6GniuT+NcL#e9Ulik_ zOR1+6{Xe`Oz=as2Av>H@+})8e72gOZ$7|1WQY`5Qms-&_V5Ph43$uTADyFN7@~bkQ zSLO6iuahbS(Nu=Q!tqmdi3~W!2~kx_Rt@kKW2!0^vSU}THq|T|FU{9VxhaSG>YJ

SdO`o?U^bCPz6Ehh!k z$iWoy5;(rkuX8fgr;aa6Ctk;PqW79jwVr`$<8zxzba{NypJ@$l z5=}YGNTKY^CVPMFv|izZt(=n~ZASUrdGcrj2!jR42b+d`u4%~U9RMHcYj6;slDq%v#!)Pb zb~FxQVGheju_D^oGmIvUuFT<>>Q?^C;kaR(FoZ=poVf?pDinENSf?1hjyip!#r zz%VYqx3z!D-x{n9)>eHUhlb4B;Hqe3mR7nd6bSL_Bi)w<)$XyULxG4HGVjDS3i*#u zD(u41^0iB`Z7(A~>VLC1BoyeW{_HSrp_zGKW7E%=rA73;mL@Z!!JT+#Mq5aaad(Y7Vc|`7A-P* zs-LDsBltrOf2w}|fLXteeaC6R(?huUu)j*dUr7e_*<-* z-Clo^2&zi9qmeQRaP>$O? zd!qgt73?ajQM0?sTPt#EUTsBB*RVP$rxr48a%#ygWW*7j;)aM3;!=I}!#(ubqalNi z7*$J2H>{S?ollZr9~wdxHR{NSS#}SMXrzDAA2Pb=?#i56!C*esxf^r&TO^ED@?(B@ zM78D=jen7t85S7chr>c;MK_iA)TrYpWkyruikw>8tuIiV;O(8^+eg*OQMnDnYTbSE zosVseYSU-`RHIHU1eg0*g=_d;cn9vn&78ai-o|lS;1EYtf#1b`4Ije88vcRLx#Xt*_H{}a0437VjmMIokn22I!?nA)kY1IYEV6mr__b&3JtGRS7~^)x>3WM z)QE<6t4B3_R6VAi1=JJj=Nf-jJulFAmG650Y}KM+K!trb`97y{fr8)S`;x{53Vy3^ zkH!TGKH?kIxIn@0_1&*=fr3Ba+oykVfr3Bi`<2E83jVb3IgJYx`~}}j8W$+|%f44M zE>Q6Q`YSXpkhs6vzd&#eiNmK(W7)kNb^pUT29_DaaM8!Sicc`!mw;8JCHTX#-&K#%VR)Go<*wT%b@r|<54RLvX;}sk> z#tvP^K3yQ>NGac)`BXZvp{Qdw-`rkcEBdGc@OC>Sew-$4Jn=sdR9_IOCsP^@v#&<5=K#u+X1C$Ums% z`1RP~|36Sm2MA9re$SKMfM|bLYdzg~w+f!RF5;=Ecq52{A}9!6rn}Q^Gl=U#%m_R_Je)V~+@=g}C<)yiH)y$aH%Q}5 zX_>1u@!~Wj)(vTrmUy!*trxT@xUrqsx;rhYE!EvD@?x2Js_@usZpnXeYnxfq_?d5Y zv}VD!rMJc{C6P*qj7lO_yJRe%#d>3PeYN3*)OGKNAOtEGX~zU~s5A*Iq$ctsBSTI8 zt&v$q#y?JMF14Qj&IiTC%IFsuzm{F;Ynep;S@W8Lyo^Ei`x-w=WA+<6=`kwx3;$gf zT2kqbp;NL}Modhc{JLmdO9u#)3d59>tb$U1d|TCd|4#I{lB_&z$4Nv2xv^tnOO~C4#tsTE#|hwA zd0^*(NJ_YtuI)=CU7>pw$Giq>*gDwO(XzEkS73C^Y-L@ufgGAbVC#Ug(XM-UV{{ws z9xYuvwr+zBy#IIZl`T6mbX|V=>D=#}?|kPw-}nC>$FIEi#pj6VL*h<(z&Lfl{(TZX%}Om`1>i(4!EM@rc&Caf_nz6qqBA2ss2UNrKfm_4o+Eu4k< zt(}*3ZjER3VhsZi=$nmMJ7qspFp|?VfAzDuL zVG7gYAo*xTm;w~!uT^0RQ5}C>1b1q3*ZPecHwqf9c|q5q+mh0mhS|l3xs-J6kj<#s z*8V=5*SljM!<2o0JF44#S|?*}rM%vD^WYXm8VwUcibrtQ>PN4?Z1 z=$7lGchn4+ipFq>Eun5`wKk|3Q@7N-X{%{7Z)-+g)$$Wyb96Fvt5e;1q5wkAsJ5w& z82OB^?1o}PwpK){Siec34~OVxMpye> zo8+||=L?&&P7N5}!Y65hc6~5b_;{_zSDitPT4NXPn-;VJHN_a2sN}>xw_pj{QUfI) z>_h;3==$FH<}KX;8bv9QES8=w6%Bi$Yd3Nl(%=qbROfIo5MnU5L`yyme{ZUBrt62= zGGLm2W0Vcitptr%R%_RvFO+PE(6yXGCMSov$~$m znwB1>pX91CP9K4sjJyy|LKfQ|ru*opSjbO*SFTlMlI8 z>&(x>wzhe_e!|&v0i0d?42e^8NEi+rPb*}4S`Zbo&LX%>V{~+VuNXzC;HAiYih&rMHDw%by z`PO_2{Z&n#oHn73X~%VSSl9Eat>qB=NHpVyJ=WQp?=$lwMlq+_W15X0UENTS zqzsjE8`MJ4#728UMYvAzSxz>IyV<0F(_Ke4Q@Phr4GYm-H_x7f)S+fjX)1I27YZM87#%2AW1V%pV{&u4vXl>1!I-B2r=CoMY(RGtiaGJF*h3Hw%dWxR6xjqVt%;~ar=1V!f zDBTX_&eQYE|H2%3RV)hq9zqSTo!w?p-YW^gh0w;_6s{tn%xES)o}g1Xw0wM|#K%-p($`@BKl zV%L5fUa57ULjMT3jic;;!r=eRRqdbXJN)wz-i4wSl2GInkqy%q=^P{U`_)x+Z&e`u zD_#P9W(i@+O^V#92I${7qa%X69Q^_M4?zM!`Cl-?f{!|d-r@es91YX|a0LE0y^HEG zh-|`XDnQdv47PC_g)m;nfXcmM5vI`s9K|4C$HOG2L}6pW&A9LlzqsmdE0qc zFKcU`*O&>vP~dqHVD|%yzRm*rynv_!#&%St;(%C;X6Sw1qKa4wbTcdu6wwx4(l$?< zxnx+>i-wR`CK~4z+yz_rs)8$;U~;iS(E1_0h}ckzx?L*fk<_o>zkeSntANys{A*@( zm{Y8(Joenv6@dqTs?RnL3?{2g;w&bi+8S|jNURo@%-xn$1YV6xP{g<<=AGvrQt!O| zvulvlELuWhomh{YiWgUJ2~`2v*{Mgf{d2`A3&}y$h)cx=HW!|q4Zv!;lts&Sz|xDo zqmURDQ6L1%F(8Cz<8nG6;+14{flx(sL6oK2gJ?w1w(WC&t2drS3%1Sks)yJlHiyJU zaT%-v`Qv8s*nU(VvxFQe`om(2=ng`s9$X&hxJS=$c-y$u6qkzx%fQopiBv|*xEx_| zrL%NZrM&SSu16W3caLkFHfhlHdLNt~7TeJPieAyjeP4~Pu^LP}8BEv0a4KR;g>Z%p z-iU8;P_LbTIeExL@~x;pn-m1zhAZ0^OiyArUjgr{WuwvrHr$eQnpClmb=)X!nDh4q zblExw(-49e?*$HBXKH@kab|(B1L9yv>=%cy!LYb{E*47#bU0y=LQ==dO+Mm(%ZP9i z`i4;ih{ex&J%7QUvF1nh`W^a+R?6BHdf&Y5IR9pUag^PB%iO;!{a*zsVi@JQ(){7E zX_u_NFo}ASl5CCoT{bOV1iR2VIaU3WT1D=kdhHIl|Y1hCxN~V$`Iz@XY>673B zw7rj1vmLmAtq}D*L#ajdJhfoHC6!7>8xBv=5h#0#+G6tjb+L1FGb?x$^l&QqA}x)7 zJ?DLtf-%qLN%D%9s*lKAaKvIsLrNGf8;l4k!?X z!~NK}53^$sb{yXN7{oq|*-7xd0bsm;g+0^Y3zAMFu2*@Tq4U*_m&kjjVeBmB_nf0b zD&dVykyXEpz7$CKB3^dc9jR{r!_*Lu_&iPiGX2CP+)bZo@-KRX{r-A9;w{t3GJO>L z@5lZrdcf1|Yx2dPdyG2cO}@+OY5MN7^k6E1%@4ugbrJ8fjb-}OA&AG+rw^Tf^Z^lH z?_fEPr1o8%1yGw?u*ZWHcXxLS?nQ&U6)jfWic5h&(L&J_mtw`;rO-lfw*sZO6ewP_ zSYP12x%crhlgZ4^Z+Fi*^L?3on{)R6VYgOClQo5HdPC|5zEXHcl4#Pgf4=PUd_uL= z05!G4RczFp+a!clc&B+xg>wuFujYxXsxI|9hT_LbyLF!hb#cOxgi+o^B^n_Co7yy6 z(jP1a)bPV>o73*~IDFfy)v9JzAm;9US#Q!?BPqL~G*8NXF0jn%-TpM=X5|9S(xSkn z+-^VP#3Hif^QaB8E}w)INtb!51R(9NIAxlC9`VsD)1y{*H4Oci(1iHDS*K^~<5PUa zgWG<;H|gfjj8_A0mG%jKAI1!xatW~TGwOF>CQ7@Qn+l7s*(CLkP1cvrxEP$Z^4@Xv z@2F4|FrSt!_>y7*fz3z;^{EQC^?c$|@Wbmmy% zs7(sdcd}mJ@ZQOG8y{sOS87a8p*3`hiQHxT4)soFUP+1sj!O|RcldP{sIG`XCASkt zWm<;3?Hjh-O_a(gGE4R1oM$-uhj-aT4hv1)Ri|ExV1cJ_MX2%$fWnCpHLGs#RYg*E z;6#2Od81}%3{Hk+{8J<7p;#-_lNmO(1cS6|J_kA;HU zAzNPL#$HVj?8mx6`TM9Om>$uOGJhovF9Lk^NhBvp&0OFE7Y(_pwwa&>0Q1J>ceMG%3duGQp|?bP6GzT*7V@B+NZ@8A$6 z18q;%T}|j%IvSeLf~$k1&vNojaaT_Bn>oA-t1609skdIudc-X&*XldQ@fuk~?~#Ed zXX04m+;uGH{)C{Iiz%)6^t<<@vc+_uf&<9`9qk;?+B>>(%xj9d){hbfE@-7~#-hoG z*N?&W3(fg1pqfFSmBgIf*-#Bk>fzo*ljFQ`O<#~H}qrsL~6W4p~8Efb}BsKLsM3oLat7L1;nm+(AIJ_OHsu(PEb zmw7c?nX;x?T*?=#wG6J_tKhFKGxnQKvburS0~$ApS-J$8`*+#Ch-FJ2>pA((loN(qT60_48pE z`-PoIDMMkRoBmdHIB}QJvbE6fm4BlFGYmY${lPFwKOMRr46|L!^U%R;;7+wgR@i5d zOn~gN&d+BS6Lt0_ar&w(mgzdr`Uq>|3dm4%L4x)MYns%>$`u+L<^Eku09>V320r;1 z6aAh(5xf^#+4V!cD9-2m-qopd_@}eIS?7KB4i#Q?(_FfgVg+3Kvr}|FpbN3+_9Z2| z!$5I6v9e;?xelxar}w9#b2Rq!sY`FR2k7+2xot~fJD_q_y(KXf!9p}ImQYS56{$Y( za0_YeWBtD6ekh#8F?v>F;sO9;G>`ww3w;m(!wt!>esIU)X6usxH(cVEAX^R%^z_H>BN=8YFBaPC?%iQcR2e$Xfg| z@Lu~OR&Ua;%nWGYXcCo=Bj_dfa>0DA=g?7KlbX!@>H^yx+FXMPE&Q;6k_3UYVyhxI z=ZYbhy_Z`_Cndm2QNKeN*i!@(pCsi5dhxA#8ShlXAKuVSu;>tb43bpPf8TYJeKU=z~RPxWQ@Sp>{~%uSFSJFGHCQl zEQ-YmJrgu|0~65)=@^jKp>$V)q#G8MLlewza~2E~NRS?1o~?8z+LU6+PghYaUD@tv zsX&P+))C-)9~8|5Ys~=Gc^5Q~G(}6I7bUNP>L??UPaB>1eKiS@YhPn(jlB>BJ9m!c znuXo!Y>MlqUy4Exs`h8~=fcW~Whu3}20k>GoV3PPjZK4RMM($>yXd^ULe*)ZuLbf$ z@1t(U8AlSTCTIgF!~}4&SOzrX34s_>nTcw}Z<2ET(4c4Ec3jaU_=8`qZP~uJk+j&C z*o1TmGcE9-AEbG%(f7qA-Ubg_#i*GihhPkI4pWoR+EC3cj2LAOHl*bdd2E2zDBnpG z&uA3pO*edGVO5FDbdIFEwgYT%Mq2Cw#pdJ=x#N%Ivi;6-d^g))BsyE}e$ksiq9bly zydi(M*mxPxH;iF|P)3kk21=L!)IVo`|E9n?9w{S8+k)W)4fFNbKsy!3QLyNlUGS^P?J7uIvJS{#VAD#5H35gxAmN=f7ZX7Y-5eBk_-W6%C`q zy}8T$7(908u=gD-l!jJvjy%oPkT)Gd1av|zF*_m7MaFE>Lw)W%zu7rj)o*0wOz~Oz z@?1zW1hXXY@!T|hbm=HPtW%}K<8fg7G&OKvE{Tjxg|mIwOQ%FMK`BN9)sEG{O$O4m zk@p@Ubu(2LUDT7$cdX2A2o~z}Z&ovp?w^4}x%&bmRN#9)89MTMTx|VFJw3R)S>ZN= zYl-rTV8*86unCGHY-wT|v2>y$T!oX`G%n{X4ut@RJK~WGIW-uj= zQ>j(VA#DeyC=vGh?-%2cgl5zSDBw4H$^v^hi?g`IKHEi|ML?a6g?Gi`K-?O{RSoHD zOstehlo(6p0olcvE-BOK;d*&~Xrf?J_2lr>AD$9g&c3`^F}4VfOUf!~gNfM%N4xU= zaX%m!i8dYZ$$2_HK2r|y@ryAuZ@CC9C~S8euhb1Aq!UX8Uq}ndD(X7BLU2gc`>>JU zWeGU14Ex2c>LGz$2kj!! zFickTd7?ZpC?k4fFl@=>)$T(M#G(d)vKamRPn?rdM9z0wP!d_9EP4_QZU%)bL4^?Zgec^OeyZ&&*o9)fcz8LTS-yu%j4v%$ZC71%Xh87Kr{R%3Ra|*L)Nz?Dun$D3CC!i zR>D6tIj)O}Uw|N17Oo~4{(jSQvH6RX{HU_iaaJOevC+T+cR@wU#>;J>Q9f=Pnar-) zAuz2n8VxSn1$4*?0qTJNPX0wO)I4znGRW!O<3jN`>8y8l3zH^Wk4hg&1;<2o|#XZ2ukL>ycB@tCZxXmb8>%nIkpS*M;zxxy5 zjqd7V1(X!Z7-4S0sa#tkTVCl?yuTnC@ZCq^;vHc$TcwZaPs;_zmt*|-L*{a(`VDx~ za&H^m*$x#L*EwIhewT z&T_W{rVZxo4pG+JhgaN*7YY^TfXP+LUK}dzU$P`vW7sDi$1b4?Q{+1sbM{D$>?B$f z)e|Ed+$Plp2&#ENkUE&%t3f3&O_YxX$;9D@qLk>{<6RMTrdO)83&&BN_I8R35f|Xc zW&%K)@t=_8te4T9OD(v2qMl)?Vg_1H=g`a-^9{$~_tcPr> zAAkej_4%bx^nPnTFFemu!gQUq3Y*GD@&Mi(_os2Pc@~z>tB%FRFeqM=d&+5k`dY z&;y-6XxU~%n|pfj_m|3}V?7ea-ASW3`NP-;-101=_m$56G!p_s+%*~5#$Ae106pWp6B-@GARIlSOK?cJWMt%Vac zq}=#D2;#G?VgGi3>`B-Q9%MD{lh?Opf^M$`8ciq1C@%KxA}?Hx5qFx$+k@#&$#7pv zZpJTOAdOyxP}Zip(OpRO4D7{SSdQ0p<@*V&#~dBY#?pGeHoZygLkHU2E3C&*pYV68 z1vt~Jx87cLpCFy2=Lw^0z+^99&Q(|p_nQrTuAK88X4Bh5q365n>@~kaGy=U@x*d%zjSyQ()Bw9ra2AD*8TRFvnATpY>wlWhho?NqJWhTUeiHz)-o3 z9?fPPT?Otv^^chT+@;5rnMD+7&6h*Vj%3#L7@~yV4fIVleAQAc;_zbMgO=jO| zs3jsRC*=Mvi`G^*hlFoaCWQQ*>0yh#qy{nPQUZ9@I;~lQDjC15VhhhW^5Ud{G4Gu! zAx1VoXLu%t(1oZdNJR@D0dl%W@>93Lq}anm4WxmR&Yv;WmZIm5;oQO3KYg%6fEg(Z zXH;z$?ZpkPn>BiKzG3%caMj-V2kBRekyBZjgoL?WweA4P3|tJFU~-#0T%l*HP>z#E z8UR?*CZ-yM5%NozVNq53_g%DodfZ+Yz@@7)h(kI}Skp^H2bDV*<>&R_&;f=JiOHC_ z6i{}>q6A~K(z%218j@0S+y+QlSBEo@52k2-_A1mdW%%ebZ|;a9z&Q%7{{X{OPehD@ zHKP|(O@BCDEGIcHUoq1Oe75KkM5Cuf$9|OrE1?2}W zC1N{;6uM|i4qS_s%Ur)03D$D8U&o}lTagvo*E?ME5dZZhTxN7VVp!gi^FEP<`1*)$ zte473PaSU0rnx-mvYK!kjo}`kf@vZxU=}z_gHv?!IDK8zFV867>5Xj{qN(&?NH-G4rC>uj@ct zBQbrbyD8sBD@|`tfy8pjUu!f>U6U2w+ik-l+v z{mgt_TViVEb*7Ea(ac`{y|h2p_>CI|H&E^`c+Z(y@QlYV>N@(z*Z3PZ0&bo~-6aqI z2AN4Zj?`1Um!)S3?keti)z@zD)mkqqeN+^ELkEa@CZ1P)E$0x^U+(@9^!c67kXMOb z;xU!1mC?7}lshRC!J`dmMiN-9h<=U!S5W$b`^_;bH2d6N@hK|{vqAyfz>X~V)%-?KHR z4KBN@Ilp8@3y!T^!gFx5hw?W?52dLOQYatR}#@;3ZxZ`;r5fh}kig)nN#ouF6Ewf?AaE_U{Ddv~f zuK(`fH?t9JH)y(a0#>~ow={O(Bzj2a+x5WJ_h;U@TJX3rt!H3Tb6k$MsWyKd;+qt# zCTE0YQYW&M&*KZW;9bCN!QsR;^L@_L>%+eMIT`o#CQk4^^L7XIxCP_Mf}*mf3>7g4 zjcy-f4=0$CsMwT@Wda#6doKK)7@YSpB$U?=r`B^O@EJa-XwUXNC-)=QSg3J&|6SOV zOz6_Id-B62Z=vp&VhK`zrspBVRvW&5hD4M(-^N}MMNY<6jtK{Y`?KA!<+HSUrESH- zHpZ^-?qU+9#XlNPypHX8h8l|}p`R88{c8?aOTu~zisfm{Skxy3%~s!H6!9r=hOC|ShnFP4mPD34EOxBot;zk7m}{G%C&hD@~g zbI-V8B0DF?@)QdmSpD2zrZ{QYj`z%3%vvI@x*Dgh%WVVBF$Cw1U-~(Smw!qpZU{gWOQ(G{0WvGw)GRsrOl1{nXT-?l zd0l;@Rn(XCp)ZRI`{nZgCK!zQqk@Jg^vPELJjx5404-bdAiTw;h^yDCa*&ncS4b8W z;RkUL#S#O`SruC4hezTjn7jlZ0QEsu=YL<}_y9;Az62zp1P2Kh2$9ofXt{_Cn=K9BKSRq9DuX-v> zN}B13Zv@W+MGN@~D=a+B=RYaL|4)uVP%34R9+mtc8kL0bRbmj-N;=4!5=O)a5i3Y- zB@u$91OO5s@won!|4Adk`b0m;c_{Nhk-}3jo=^xN0E7xei~fJKlprA` z)Rg}zPXGYVpLobCKX@oU%!A@V03jZ>T2!#r;(kIUt3tYJ2q6O10*H@2-Ce4Q;G@+a zZ9z3C5U>*WV}So!Tmu07PXefE{|l4X@KXHWetfh~z)rpY1(_)xnzwz24W|w^9L^_@ zjRg!+r1-aK84P;54h2>)fE+?&ijDy5_6DITp{MxoU=RSn_9PmD^&>1*%ZB*4m(Hb@ z2>xf_<1jL7StuU1d^x}}Y{TA9hk+3D2oZAD*;$VUcWLcPH1AXg2weU~n44Bl!5M w7PgL>&;I`;?us74{(3&7f4)He))T^OmOUET88lJokpk8iEZ0%Y};;}6WeLhG)ceS&-?w@`}e-CJ+s!V zSu^$txpxNH=!yz#X{~D|!Rqmyk#lN~X&X|PN-VC(*S{l4u_MT_%(!w!9}$Uk0n6R( zL%pgVFwqG>LG8`I2L|;5AqL>R@p~M3nvbIPkUGU1UNfg*ZauQPW^~muS7cbUWCOm& zP{?3pP$V2-95b*Q&5a|Od0agz$|zeVRaw(<6XfTiP7(r>_dccUn9+Z$OIAQHIvk>h z-e>>h2IYFA7XUz^RO%fk^G>D!fyWjAhD)3jY%kZDE?g1Q*iyD{vH)#Q_EPC(p+ZDo z$G6l>EuZ$n!Tme2S}Diy_50eVaJN?#7YR``jmssIO%c}!MG@dX0H^-IbZ zDWa4@c9N7UL2RIv#+LfBDwa`1TUZ--NgTb$yk{XDga}iYdLMp2q=-Jw!N%7|lq^9Y zo1&b|ArSu_@%g>sA`&2Q_>u}xtYqFt#F9;%Ym}2ZQt90lzAoLHBd92%Vhg~=HI%8;6Z;I*t=r~oAQ^(Ce z$tFjU=&C6`fx|6I?f?taoq*2Q@%?a>ww_4Q%-Uj_?EQkM+vm_GPu8pe6lveXxY+Y^ zlaZ3m!a!*IQDy5e_qY)(ho2=cabN$zhJa)pbf5?==6-{4l`i3tma zC?Z6zT;g(>&7Vg8BP}62RGt^}eAThhTWwBoT}c6txFgn+IJoQ(LR26eSprJFghhedF~s`k_<91{q-vf($kZLm;kO5CyrANi2N;X+hK}}o z@as!OVxzbXf$(^yWI|Y`sN^Ir zB?!|V2r1K0hJ}7-BqX-ERHDXKwUS8|6(t8X#!w!>zSVv0=Gwk~WmGdVfqKvTDu$UV zi3$8JI>i@APR3=|qu_0AlW$|~?fvVefV3Z?mSX(w{O-=`K2+^+tuL{y$y(Qo(nU9T z&sCVDGnngR0LM~i2vZ2_X#2Rx?i$fS)bXtd*ra`GO!pu?%pSPQw&M-hGB)~l=NjHv z?K{-KE1UoTv+&+7Ys-$OiPPx_SUMqKtF!#T&Cp4YDQDIn8)s*O?Zx0qqt5TlH)Vr5 z#v&SZQo&-HXLf|{n=dmemu2lh49_-ckP&y{-VBc*0O3DC(Hp5n`BHJka!}Q3z=x^< zMQ{tD+ULXQ*<(e#%Ls+dbSD5Hn|6E<=X~>)+?njf0$ctF-ho@JX$blCV`z4vyXJ~8 z+^}bP&$vO)zS}t#gIc#u*%ivLFWKM0>!-nIvwYTjbA_%i^IV|0S6i~n*6oZz!EeE} zX4zs-HBP1tuo-@B6V3`YUNifMI~HU>UZ`(NITas%uRt*V2`qLk7*;~QCjrYuM|l~S z1M&Q`t12hy5_>J}0M3dxR$gv<=$g;jJl?Dt^=s%L+F@IuGen!c|4_6oL~`bMNPKsP z3{U~F7nSYof-?>~AW8A4y2-+_G`)}f z1N+(~`BOnyHQJJp3+vDJqY~JCzFii6h-!+s#~}p#b!7&& zX&rh-Hq%+v(=yR%uD;Pl9zN;=|I9wt1j2yp*=&rKOkiZ1qaSKXdzZP6@c+|V3SWUFj zbA~b-Z08Y|k=kf$IU;60v1;)3x`0jkyh||%oLyWCw;JbSB zl72>_X$Ofz_Y(ZmoReF7cxu265&|)RIB4e%)WBA$;N~1X(r5M)1WW<%>I!|3QHIs{ z`;Ojg!Q`D?4B4n+P4H1m4B3I4$IIc3M5A-eoE*>T_m22ewptA*QPmBEVq?caEAk`x zM2R%lVcLr<7;pIMv@tf^3!c)zF$h@vWVbC@zVU^_F#m6lqEgCuuoUA;du$#rojSk) zLMa&ByKlI2he)74iD`^JOWOv70y9sSLdLWT@fUif2r`(Aq*ONqiSaS~W3eF}ENosS zo6DwNGeHCIFn@rf(jdHa=!80evnguFroVx69AEjI^tVjQDcD&(QLGIZBOq&mCZk2S zCJ_L2?UYy9$B%Ef@Ov^~(|fLto7wD-Ptb}KV|WUn7jBx&EZVFxEyYry*E%`Was_*G zB{C!XY~)GgaHo%H;l32z%&q#*4EjUAXdkvd6HGJRROTQuXppi;ve;#6;xH#AHh)wF z%Qqe@^$ytZd5ULL8TkV&knbV0AZf?PK;pWy6Ijgd6N9@(Z-8#@re!mB*2e~e;NNWX z<(>%4djki3OMp(M|L#9VegX;JZ;d=8l zCvX*q zDD9}o*=x1@x#$CdQ;{`YhT`ABub?5TqiTH1Y088SNJa_&E0*V1NMXQgA0%kuFWR$J zWH(EvJQL+X)`C2=Tq^I8_&FiABP94Q zg?%_$KWR%~Tg|-+V#zK>Vf)WN|dg1*vZR zruO6PApbS-j0Ley-b$GrdREN}OQG*@p$s49m0a_tqzxv>$8%;KAe zFp)wNc!nz{AG0}th**_<$wcuFInbJ*0*;33gL*Qq`HD}Dj0hJ36reY-d}tZpN0}a^ znx#cdZDGLKH3sCiTKbVD{vw6ERQDQJLnM4fq9hjf=h142pDhXN&?>cv25>F4~^fGjosOWWFOpM zRyS`dHg!A$EoHM-cj%=Km6z6p?b~JQ2Q>iQ>!4228bX40>HwO^il&G$=O{7>iOwEj zBrVTX=(OPM$pxoLH0G^eK5WtqT&`v=$*>w(_DNrNv=8-1Ypi8FHr_hF+ZBzFJ?3Oj zVkojNPXG~+vYwZ%ciz)XIUOGbALw%jjy(Z6P9`aoU=K|*p8m{LCzAFVK4A&V05RLYLVdC_ z_&I`_Hhma2z5bMj1HU=?D7ODFJgbqDarCj+G6Q{+%%-d1n-qNYQX|Ws@l#96PcXuv z(#_Bq+&-d6-f8-@B3$;jxKfBe_*o9I*)0k0ck~Sh`wpIdQGLW*!U1SOKR`9hnnrf$ zGFitw{g>>&D683bj@vHuQ}>KU2_9>685SlFb?z;`9CO=)o=-7?m_0&3EB)4#^;ngKmEq&zYmHag;poGfezAyQxDr<@NE} z`Q(oN~pfBiw+6ZiBaIRuEhXf!WycIyB{pQ(^9C0i}&J3ac0rR(u^OFqE$B5RZ~b=p{LF3&V(FlYCKdBV+}3 zvxgn25^#6N({f%h0x>1biUXOmhZWlI@(^SF@%q+thF{LxUp5A`TCjm%dEW1oG);y- zIXZJ#Pv(5Uv`uRUuQ05dHF(PAzt+Wmul^1!COkn~g+ zeN6(URji*!bk}krVu$QbR(R%`!5xQNpZE^HO7Cw1tr3H;1A%926u~={s}VTO2vT!i z5oyX*D@;LI*3jnsI{EpcslSl_wP6-*2{KbS2nYdG2nbaLs1#T!+?026bs$t%hO(&u z`rZQKW|Nl&sO$S8-Qo!JVC3LLd-ty;t<9~nYuVT&(gT;fP#OVD(O0NmJq~)-v-aty;i#BD*gp9785RSy#rV(L^^ES_fXwtaNF)u!=8C# z)wkaa^&Lu|a^Z^LPxATAWRnSZ_?{zzd-3mO>F}&7i6@2G=q+;1wt*^|c=Tf+VIV7X!8c|X05xc89 z)|(5dg|H-$1V+EcGg}&#(h>=&fpgEb`VBj!09{n$DP4VmNleQ;QJp#O+~UNdSf|7* zmr0?e3Xk3wgvDtYgJi$rsU~zr zmqIjT#^pbVtsIjbDgGO;GX8J8>ZURTiWzK{8I~D_X@-EH6`&+TfD@iRx;WnLmfkUF zl&A-suM)@^l9;3e5ghqWVx81GO4dGok9oI-Co{LAqCsEq#)XDY4-Z#oWLgKFg~0?D zE!8eH^jfR}f6`|IYtHPI7tz8L%#dynIBr~3mVLtdPSc1~@^(+!Xw@(Js`vwdCe4t@ z!+4~Gd3codGlp+28ICy+E)fotE!g#To#L|7+z7&GOC`EtHlQ&O$3LrQMFrUukko1} zcX7~ag#?-`=2|X40x>UjIhEl?#}6A>WTieB`icK)xPs%i5q14HMgQK$MYP9P*UD@7 zw!+DE@s}QO@qir6vC~1pIjjo2z121Tdq;d_0EYZkd#wLSG@Rq><@bCS<)bFKfG16S z!@e?>0ZA5}4v#fbZ2Ogut(Con@4b?&QuSPiU~B>1WcL_O$jM_}vElb%=M2>@C*A!w z)*@tTLf3+KXLT%3%u%q&Y$)z1l&ADUsIk4#;w<)#!o7}9luq62#Wz)8^IP?5Ltz3q zpYMr!UcUJVe*LAgQT{C1W#hay_1$*k;XR8^QwaGG;SGQDhK$a44DA5qR>H{`ZdCMV zC5!GrR`QN0w4JkCD=GvlTyL_0weG0ReWN|b;P=(r+kt(2(I0s~^~{6Bi-$mRBY8LY zb2cu3i9-N26if*Kx%>`@>v*G<=5#-j#xzZa5NkmZ!ro)LP|YLQt)#{XcS1kG2H4J? z?51UjK8G*=N~_uZRVS~ApY2#)S!}|~xDjTjS0MXqA#9T=d~muhTSZvdz(jx4T1LyI z6f?Oc$@aElelfpi{MrirWO1C7&;Z8tVChzP*nzY1auPO^YLy?C&~tySz|tc zVK#lPBx)_|n>#LQ_0Ih(s2=oDY?L>^GBhnFtYRDn8t>T61(T8Xc7rV^(V?an8xp)w zd(m01$h~k$*zT(MP~Asab2NE$&t)nw!$s1vNldkZ!lCmksw^%XB{xb))>*vCeoYB-`MJ{F;9c7_&Rr;o7KQOPy^=MNbH>&9i< zU%QTnT2RE<~unJT#U+vWgSJVjEk2Ly+F&^<5opYJQ6I@uuPC&6qmTUWmE4 z*9~JRrYk9<=kzBx!E~J#-U0smu!1y~Uq$~6@W5m#;*


Xdyd*p!l1Y@nCA zkqV|5mav3F`$}CIvn~v_k?Q7B*`oQ<_j@sn0<>6eya4v)opW!qeva_Tn=Y*^yeQ!|_JrAs%LLir z?%?aYiC@Ay&q`uFSn>Nsh0`LaUceI8*kRXw(57^PV3DnDa9Ov|!nN)&*SY~JNgJJZ z8|#BV)HpfCmB)t&ak$M!KHAbRCi8?aKo!pYb?chG0q! zeI*6=Wpt(CrW}L5OZWM0nzBvaZ4WqS&V~mC z&=wc@kA-IR5CWaaKgaTdx3D?PRH=rsvX|+_kEn{wuPlbxF4W2b)0B~97a&J3)jlGp_>KOi ze5R&4FXWG}EUBa-u!je%Ay~@#wW!PKUf})*A)OwZ!<6q#7Qk6~D0Z}Q+O;MYwxrAq0{D2vYgnl{Xj|T%O1Kf_Iv% zp5Fc*$N?HAcHaPBzJ@)15maZ@@VPe3mfUL0vkposm2hq6T8Yvgu_z%ij4dIzP##!b zIbP-5Yn%)OZD5}A(OAzR;xzp5?Bpt{gHb-g!UlQ2y&qFpUvbs_t{e2)T;zNAvxymq>6wOY5+ycnxMdvK8Y+Ms@D=G9h*QAY;O2HtgUYw_C$jqbqAP+PeVg-j}!dT4E)76--?}`E~R!5-?K08Cy-0Q zaBYPh82SI0eT0IF>>#7-Z?=Q_JnD24UR=3OGvnW-g%@$ zyKy~4ArAL6qz`j1lUNKa60erJcem@)0GS!Q0si$bl>CL&s76ltEzF2EX@ z%eo%30f5@xzeRY3S%^J^qXo+~3!YJ@3k$5BAAUN$L!Srj%k%n8kh%Zm^rqn}czuVJ z;CSKcFDfF%<&>qYArI{{E@dkf8xH?TxVR9y`;*Y(%t!JmEMj`9>W{eeN)SuG6!8%va) zm?M@bqh6-ohJ|rdRCAk6e~B$Fr?(^603cywC`*a^m$FR>`ubqKx_c-({lS0$F>{hE zfr8m0d>4KAE0cL^$|W1UV?fGl{9u*@02h_rp2+;2aACw~=y>fq=tr!h&VvG@4-96V zVOyF4cD(Dg1EX;GrPF!+^7&-{nSUvtbOJUHFCkvw8krRGc91{xp%>I4`}aXdd0AD# z75*k0rv>1&u-Tj_6Vsa^YCz*dqidGU%eeN24>s zU{F=Y*`7BFQZeT2baaDDv|0W9c077gr+0m~w2|8KH;rG)MT`4O%5HBSwBYkkaJ_`@8?-vAfC*U%zjU%M19KKxa_$;-BFIUS#4tKvd zZuvU9b_^QS0H-MbjQ%d2z|_S!{p0(U6FTld(GC2eKY%*_2`VrY?4$}VqACM>*Ku;gtm$b! zhKwsBDaZ|j7(Hy^OaQnn_wahak*lD%YOy5<7Z5J@0u^n?XUKVl-ZbKB*{u;I4d{|D zT{w)+DbFky=lduaxmQV9P{6kp+;+bb%+}Z)Yz1Su<&BP;F;sd`Nrww+65~kRdONnw z)P?x!VuC0xWI0Y;DCFiW2GT4W<4-Qh5O8@DnkYN2V4pDR*?=SM@q}w$Y6pH}466)7 zt{@z2a1*DiQ< zh^w_!(HzHXM!aO-oFRY-!d=%|CPYDxUPsy0m~lIm@b!n7I!lE9kvCd%6=s&~Lu_}V zE=T4)u2V=@K;?B8ci-3|;eMwS=^Rf5S1&p3QKsWG`9dKrk37#**T*_1`u=)9Gs^BB zS8JviR<+h$imDFb>J6&Zs*&9x-yB1{Nq4w{a5qA!2ihi&Ra`5ESh?*IEOuUjxw#un zG?wMlOlPVi+-?F?W`)cm}FJVa~!;_h{7)k)c>-iatF z+7LKfK?r0IfLJI1z#KKV;}c&9IXs%R@}rtNrGhIW7fukSQ<%4@I3< z^Q5v>x$7fWzml-bu#SV0X+DJJLL4KII5e_T34xEuLy%f8dz(hl1?1E7?Ys0!&$(X7 z27(I;P%>pyOVXsIjPFM@x=tL3hk2>$(PP`7!o@>E?tSf;aYqD32x~6?Z5&f}QT{b^!n2%}MzmcU5J&hqV zoLoVert|Bc0dbD(uZ*P!vSc_+k`c)bY(3G7z&-Du9{xYa+pkha4|LZVw~af^cc#@|$kVyU*8Z-3+VvN+{Cpj3J}lQ)h*1i1*- zKL%ylEKZQJ>-nYnRDEeL_~P_aT2NnSYOlNV#SoR zq!@>RE_MPo@R`~y>CISLr%kc-qc;rcv#Zpc1v&t3v3sfxeC2C8e|bfnSVPA^hX@-Y z1X?`hY#6op-dC#Q$XY-JCiZY~$$1m@=&mwDxE^RXWYo!-?^5egyBW(TENk@fghVG{ z*6+8)xH?X)A-l?M>6ycwK_k<#oOpBiX%s(jb|IG#U?a0BNfJ1)Pkkr!t=cy~A7L2WuYWM;|W3!qx5v%k_Airm*OMGvSd9& z0@aL~QaD8#8mV1fq1JNS3}FX7!5c~FOHA?k9gIY;g+1)BB&8fqo8y7*m>!2$-aEX9 zMhgR3@y|}+1x`$Mz5B(;AC7dX)lVQVP8yHXIhP=*r-js0BPGV&J_@@QJ(ev!o1~0> za|=y9b&)-WY_yOA!0~5j_XzoTt%{LHfx}==M?|V?=j|v(x}`bE?4YHSr46KnH`RiW ziI&kL8e$$CDdt_R-7)sX#52zT>4(2UJi71*CH~{jQdcb^fQQp9_%D0><^hnR8eL+m zv6PusB!YskX%x-z4982P;_TU9Hz*g(Ev`}UR#OEzEb+^AfGzL)R5SEf;zu&Zi5=KL zt3}7@{RLaB3|k$}r&6Lf!9s2ilRHR}3y|brT<2ulox~$dq%wCu2im%rXqaog+~QM~ z-d?f>Wr2Q_h^6yc%3PKr);u8J(8it587nv>ku_Opfeba>Rc=Boxq-->3Oy*C9pu8U zG6X&BpqR#%r%VFgk(fyy)Nip@{ba_f)0>&^KwSXt67!D?jXgfxvcf}!D$k2`Ol3;I zfs<{k_En&%6jOA|%V>j)LISy8_f+{=1X7AH(wA#wI*#-G!?ysFvOwhJ*HKwvs{{O_ zMBp>pC81{RUkUQC(GrHPll%-IGVwvlhXqpx3=XLwM!Fu1B0f|aFT!Kmi|B&No*j$5 zuk|^{j^`NN;1^h9bBkvPoikY?)5Q3rC{nUA-gU#GRKeVf*wXj&GlhU3CiF8AD))NK z3y3fmg&tgz>yl)g4SeU)IzVX~+kWVL?-;h4de^A}q@=&--a!JeJ5bu7f*u4*DSDPl zsQUi@?T15R?$E;i6&LmYD=rg);y{PxwYSREx)>(OQM?w!vS>0GUK|EQ@r>mo9^yPI zD;oO9vxrw*meRs~xL36Ur@_3O>2I^0oR1%m_b~f-gpduath{x!K4hVA^5X5+uo6Cd z$VN139w-NeEZ<73RP4eVyB5qyBFm zs{OwlU;!p-%5^(?N{=uuAzgwx3E~&7Y#geu$eLhp_Y^?hOjwqjg46(S%8f8SFr_UO z1Gm%W=H)f8|4;A3WB=X!U{HW(im`sr<@GpnM6&emu`d^-1K?0ebP)XF>ip+vm!p_@J<|F?;?^MXg#HN_NY z=PX+9B`sa*VT>X6S`4}I@I!SbVDby?pX86I5WECKok6@1{_c~rgD^8hP}p^Gm#Hb-i=JITDHQQY^N3h<3thj3M!@ae-vxeLaiS4}3}Bdd8u%g$)y()zm4up+S=kw_M|A;Y0F=&m^)@z)Zi!ca=M;uiPxV@x7K5$PYn zCR8ZE^`c_R%plpXeKhRBLgQq7-LK=Q8ChwVc^P*SmLo?ijNo*!k(wn`~0HN-z^3k?Oy*YE$G1B7GJ8?6$Xd85^Q57)q z@9O0TvL8;9rP0l)E*I1UD`=pyJ|q|QA~}h=hNbO4Mp#3#%?7*XKcAolEwYP8W^>1d z-QKHNs@)msIwl%{-t4m_+`~*0gNK02Iem+CVKciQT$=$2$hHhmWYQlb`>PBvHfK?7 zXSI54etziG=W6NWs%ROdE=TwwGP&w?6ihB(WKzgG18cgCI1Oii2*)|N&v4(ISy>p` zT3#vIx0PsBxpBo1fH=(28;Y+r`O=(#F+6<>6xVeZTBF#&ECt%g=%X5vmDvq&p|_CC zj(N8nzWV6MvV-N)v!v7)<^*N?LydSN?08=MA#P9Ddz9V0=3j(t4wr^=_sG>xdYe|@ zD|2GCZ>YCE`!phClJj$$m_u^QG{7InIQs1Y(=xBReaD#Q|5AQ1{zF>#^xQt#+Jekz z_Q-*bi8}MZJGIWT3>&*<)K!L(p?m6<@QklTqC}u)_b*F2gn43~$wwX-dt>U*vTr`M z@z@%#ha%d$VstphM&obv?>I;#(Cw;m(9WNys$Y6Vv{WN!C` zYtPP=cSh@UUm{u0X2&a%s!Lc4@&@zYO>ZR@rt-&t;0V5{#Ij39fKQ`{vPlEydt@N0 zTXIqS_FeDTp)aw`iIewSmgh0t@ae^bidlc@#w#aDA6Y}(-bX@vMdNSd!}Xu*oE@nJ zgSLIA<{fOvY7uJV$9A#kFYHk%LfuJJ z7o_%_Jt2`DpKcN3>A7|ueP(ty7uQxGfo3kDKL``$XlDi3NWYjYxlnE_KP~S%mk{F( z*tx$)%ReEdf!WVBSJ-x$khy-4L)Rjj1b)AKxi=$jA1Y8Y>KmPMf#|RNQsBS;zvSuM z0)H(93J^q_}}%C%#& zFzI>z#@E<)w7=hNl%-LKgkMWmvJu8Y11oR*ZdYsMpXW_{ULa8JH20@xXaC$+m{P3f z{@~+7T;ckOteKCqIiY^4mwCd@?mU_3IdY`fr8+A+Yn0ZtZ_5CTE7>WO9n!=psuw^MBW*dRe7{WnXgh1hQhZIq!1P0%#nO_B zaZi~=E{!A|Cfx*hrkFtsS$CZmBci=RXf+YqDFpzvvOEBMjsm3{m*R$&;NphjFcM`ojsfXMvgFXmssM!3gJN zOH>Q8N<<^l(%B)wS^}z3)G4yWJ)9iV6FsV(+|`_P1bdu*oJxG08bqnkI4{Slu=+G^ zcU{dYMP6)_jTp+ZHl>iLH3k;J@}b3kK#$Az9;=wXoEl6za>A?YK6}It;26pjB&Uj2 z@fByV{4?oWJB#5=273fd@FV*M&V1{@Y z2W6aB6UU+@Q zEPrvy`kFJXQ2x*@Pz>`ce*DjElf18B+e(R7v;lIDCGwFY7~-OMA3Zbh$!dqV!(&vC zQ8BrFH56#Re&*|LuE}cRCwm|dkYMTjJ`#+&UxMZY2Q6z@zPhTl%FVe44ETWEM?=LH zF*5EW35uMTGijVXDA7$gG_I}r!2<)Mu~AyffwS#4c%%oye2B_#?7M4T8kezP5PCTf zPyxzUV>aJS{1_fgsel4^fn7d)wXq;y5vbvoe$2*Md5@ih%x!$zpnh!>Jwr{2J-r`? zO%?ahoXtJKEjJB!K7QcxNyX0X^U++tT3W6~6p?*QjwOb158gQpg|9)((a6@&Pn=!W zIn`JrAL<&q!Qj^Fx@*y7>5;-LDr)?k(FI~EV`&TQ@G^6`RYbuv<0q~e!iCG^HTOS{ z+W@^|hYongciIvCdC5~kthMu}s6Re@KIl7PdHzOuQARbEp`~GkU0{0){N;24i+E@M z9IGF?@c5?D(nJHsa-KHXO=hq=4j?l!s7~%``wQdK5KQffO4vWPC2o>L@Z7dp(1h*N zN>xcs6rCuO6xs&8o|z0$rzDYx7* zsV*U+buabY3O&yBkj~F6z8$?9 zcU~HV+#O>mq@x)(2huLrxpQ2+>)fp`ci& z%%tcqpfBs~PUvikJi8c$Nla^au*~+svy}2jLtBxg*$Bj5mAH|8hvVRWA~7jHbf_8) zkyGONC=m-jZC@4fQErfCQAfE2o*puTv=@LPB{+ngnBbQJYlXyk;u8w{QLWBmHhRK= zYn4PG5Dh0(UDueU9@)^5{5KwGujQN|L4YA%y^^J$x$4=ksh%<+fs1H3b%a>u-;(ll z{O|ICHPx|}TZq`T2QLnzlYFr%E6?YA)j3^ZB^Wam52e4^8k=*4ILbHmSJhCyM`dOq zz4Pc0r<9Y+cLju;hVGD%nd0K2SPiVw#wU_BApT^w1)c(4yh&9{a$b3s*W6$I6~RCP9RZpPzgM2oUyOM<I_mOa>~1vSVD{OM>O3k)sg4~tV>J@T;v0~RnM~) zKq9`*IJdUA&?+ZIA$cm&G`SuM(P4;>0iWM9p``at=eR@xA==v0`Wi2fFCT7-&OrrT zok$kA%X+iD8+OcesA|-^lFGjk($tm7fkG8m2S+H%HWj$3qH1&Wf|>ndUw~r^t9z-}7!AK*$!a_Lr9jIbmvbK9v7py&?p+?@#A>4{fBR97aIV4p!$v){N^uFp;Vp&^FI3<(t&D!m zNrtUdC`;v(x}7M=IHN}sgClUK9Nu$Rzujt!|_w_!FM1|UEjHr1e-n|TgrY!?j zi(O=K8I01IDPK13AmHW0OGRI+tV#)FOeyj^Vtr3clV2L&dUW~6UDVyXbQF!LCfpj= zicV-xJ(vt7JQs!Y=|d$f#2HkcwQ)Yq5Ayg6G&rL3(|3g)x1U(bVwp*9Ghelw6o19! z!%r3%E!6VI$~Ch^hNI~n;1rGkFBmrt?*#a(7F&fUolf&R{Il%EHAhjJv=a z`j&S(9Zv?t?EE6l*ag*MU)UP0?I!{(HNw3nJgcK|H?Y0^`~9auSU;56iO~!r7lpV> zR%PKOaeV)nJcZ8SIpX=fuz7*m(OX6vS_9ceR=EsJh6u&-_XfNKg#s&2G*J4oT03wTRd}3D^4&1_fdq&HEwl<^a|Oi=<@RpXhf~*#Ei@ zq({{X+&^B3{U0xWYOw-!5qu4`us>ZmQ(gp!|Lt(m7+pIHf7mB}u_lcNB(17Z)G&u~xQ{-EaS~ zUXtTj3)*De+xD63J>B-0|2^M%`s+U928cGm01MiBx!PEA_xb>S&)=M#^$c_fv~TR| z6tOyfkk$=V-nS!u#N%)&KEr*kF3Yo_}h^=CAQ6+1zT|M4a^xWm>0Q7>-(i)Ea0hX zL-Gy?>& z94pp!il8m3JuNA*j9f=baF3If;|-q?=+IpQKG#I4u;=i{bAT$Vmv)X zT;{bgHNF859{qo>NOtl61@>8iu0|FaD zfgFuzu2XOTI%D)s^q9qdCnAABx(oI%78LQ(?M~pGg%O%qE?M6iXUhkvs!n4P_{k#c zu*lH^B4+_z65?^BK2L0BJnEn(2h1h@UYJDxGq)unzK*#=B8-ocy5^rv0U!CcsKDm> zB)029=q~e3UcS)xZ+iTX{wXSn#$@e+Sc`^#}2b-C@?^h4OBC*KVJ z9%JD2uLWtI+W~Czle9AY9b&`-tzG*aiGea0XUs5l63$t+giJyw;gph4X!f&v1eM=o z8?FK*%Hw}&PJZvA<=hri=$@yHWS(D?5K3zZv2NFrO}k#G!5AQ71rs%7^3q4~TxiBE zXFJ$^+wty@_R~DVrx+K}-b-|fJA=|Q=9mDY3_xPH{FbQCNjR2T_(SYmL#J3n{=+g( z6^o?uRc3M%}@zj=gfzJw*y4(&D1gvU05mFP}H@;SK{q|>gxote&D6K zQBEW%@r3Ld$G^>dj3!kG*|wZ6AMP@e1KEM%LKE&M(5vhdyYggcsw)EZlBTKCZIul? zg4CD2n6SM~Y+520hTHx+Rq^QWHD07ZS9fsj`FjR%*cFnb6vG9yf#8jHDAtD0g1)y+ z%baIPr~ZaGt>oLT>c)BOZ}F!Iw@-$tN9(=zm%7}~T{9GYv7U8>vKRJOD_5;;F`j!y zq?H&prfLv+7pq95Ae8NJ1YZ4Co3{c`MP{C+Zm&qGbv7`tH~XoDXJ<8A%BQhBC)-Rw zNUBUuLFt>$zNnFSthD$h&ABSG689nxETXxR;$@mq3YrH%AX7VYlMP+t9vyTCtj$6c zk*=)RwHk|J`0;kw!T7!RRg(I(TWoEi)Po`jl7Zn=Q_EvmPN`m+!9PgGdABE?jzecsS}jfmWp(QP zvvAEv105B?Jbus#(H_EMu2VBW59XZBUkXP|EKz;xmxTvzApWg&`d53oAK5`LCKZu* zCylK++1uP&3*8@lF}u8Xvk>_M?R2bfe|V$~G=+|hlrF~%7X?}BPu8zjU<2Uxu&eGp zJEfA57^Xg7@VVIk#dT(_ETBMH@pbD)JH*qE-VJKlD6d~yLEqFb{POjHHkn<*57BIk;Vk{p zqi5$bw{#D?pM@>1%XN9|JN`!YQgan#Z%_vvp93eA;l=goPo< zXe$4w>kZvw`wFA3^2`d*!*KK#p;S^)>2on><$5JCS~R9!Jl|S!)Z`q&wJo}TQEBo2 z0ii%%zu?3h9IdgzX_J4;D?U~HgHSVQ**V>vfto59uY#JXKK7qDB7*+{_0jDFUMd&~ zm)?VP!}W>lTD)24t=3b>4RBj>P)D7ZLQhB^eNit-Uv;9VlOuJMa-`0V#(!FxT|gAW zzlgdCH6#NhA`@7c>>S6*MJzptGZ?y>4q`dOZCFDeQHF;RPbRw$Vg;j`a&FH-tYJ6= zm38mM3C)rsc6TJ&T*QUj_D(($*+*&_UZUR^e3J-aj)H{>wf(elL_u6Z+a%fI^SDIO zA8?ph)ZgQxl7VNF!NS00k$>cl9phNrbO7zm2e4rRo06SPT}5o~E@I~eMGUn1ir}sOB8FOPBTdaq!@jUTTsw~4 z`#L9JB|}$6#^F9BmCU2}MUK2!C&v&L$#F5gvBbCpr^`{p8FFmE3V%6zE(n565=kCW zh*u|?=aPvDiU6arDRM71goY2|)pN+Nb&}d6smD+^foqe3Gmh8ahwH^T=Sa1+m~+|@ z3hyL+2Z*Z`5g)!CLDJP8`e+fK41Ky& z$ajVIkK?l;^6SB5veg%wDB|;>FV;MO14SHa^@qMJ=&$&QPS%9JmLO)>&uCgH;z{Bv z$!N{F{?NCJ_}(J_PMUs_ETrvMZVUTDKNPahR?4!H$OTejX@6N@@8lEBk*26;d=by> z_d@z}FQjvEHLj<7ZP4*5`Q}kA0uIk@-MKe1fyFKkRZK5pj-s@SLMKV3hFmys!LG6D^uNq`a_xO z5!9cK0z!~~nIioQRN-Y(zoWIbCiHy57y5g`A5GMTeF-J(PpFZ^g4(9U0;M?-IvlRO z4=?VM`A993#B0sJ0Z>Z^2oy06QP~Lq0Ok?^08mQ<1d|7gEt8jFEq~p9pjc5r{9F}E z!giy@q(NeWQsAKm(^?asn#=BVyL7*DcejQZ`62!bV}e8ze}F&AI9oJE@xhmSXU@!- zIWzZu`~LYWfORYjygxo}H{R+8(i&1=>l?b&*Vl9_^dr}ki5munAKJvYB9CND9305l zum)rea~Vp(@1}(K?oE(VX7?JaXk`P36*0yO4=ToZaYB9`mjy}=B`;LS^CU+C%hmHrR?kCaT*1{M<}lBVvt z#P@ynRxrU9u=E8puRme7QaQ!K39eUe@^J$FBkp|w#p{2W&*F9bzrF78+asTIB$+m1cq`#M+ z;of`B_kHKv^v;bd3cj*c6Q zNJb?mlRbfbrWsWSf+PFw8NtMcrF)sCjI1`s!|Ak2I+Lf%$m~p+84v-BO{PVovTCVC zBW*;osaU4BZY<0O7rAJXPUSS2Y5t{QRhoawGzkYaLRpr?OmoK_F|rHdZu00fjixir zng~jz8BFCM8#E)*m{3fCXwt~k?b#Isp;_eBX(r8Pa*f_mX)co^WA542G7hZ;X!B`- zPV>lDjMk!3B~uyBY=@5|Ajb3p>S%4dXb~;eX(3$+t8~J+8dVip&4N>D8I#kvF$;em zW2&eMjy3CsrTbk}Lw=pAsTQ`fIEk5cf@a;$aHbnZT+UdGddg5AA6 zaK>rDG5HH5d+5e8GARY-Z`6MX26Nn)jTsq@j$x%qqZ2T3x;LFM5`JN5jb6<(S(3?S zV)43QEREFpS_su{WPBE&FYgh(KC{!8={9`Z_O|+}jM}bRpT8;5D|R;~dXI(USz~Ff zMmOPvsF9AOVtM_zOF6^Mbc^8g)8BTJXZOxJZAyg)9&(W*G$E zL~qvVB;7V%m(mHMqcp10TcNxW3R}bJZiuVW+fWiLtEL-zEmq+u!D7hPa1V}qJH10V z$vejp!nR8P1p%Z&;8L@yMswR}#^Y8c0FgWC-8!A3_b_>@O2b$_`#zoSpwps|1;=rn z2YJ6vx6|EBYhEcB7Bznuoo31k=k{zzeqW^zGHt24gwtBs8^%J6Q*NH059{mkxGLi04`VOkLdITdK5DH{Rgh!c&J*V z$MBH|XHc2bF8Y$-rkcKt(vZ$}r1S1wQPom1TYr_#3+Ts@dCg>zwEHi!1iYfC7Qs=L z!?9nZuM3s^H`9O0{~TYXZy=lH*%elPN@DbM*&x&1Lc zE4ck16bQ+!U{><_Q)I72s0*T;!=0L9X%T->7yaBSald~+s?KBh4+(@{6`D)QPkjM1 z-=+LUr{9XwSspQy8FaDf?MAPQekZ!IQ}lmKGslY3kd4KoqW=CK#RmcK2c4c5t%*}K z?@1I`e@XEtAOlJNM1K|}{(}6GF|AD(y(k))=jm@S7J3Av#e#ZW^bfjUXy%_%>ri7) z+{mDJc*%b<@5|sMj=?0;Ewcd(IRrqeX3QbwX0px9_XRGt2@T)NV$hIu3g&1|MqTU_ zJ;lAO7WcEVbgEpI?_7qPs<8!OWM_km%h{!~&Xa^fq3EkG$2-PlgOT=vr=lwGG^Q&r z4@YGW5<+lHLCzQ0JGr8ar}KUM}L`4qhShL%KQ9lj(KwD)=9J8Iy%Q9ecIm zV$6RMVqxvLygOWIR`PlQfpKENsM3jsper1g0pENgV&tub(PECpst;w&m&nF5F}S$T zYCUQ--lX$J5pWCgP*KxJ`;uk`;KvMKIN57~0HoJeg8Lb^R@#f*ixmGmJwX$*Mt=52=_l#b+ z=4GV-D194m7qJlp*|BG8+y)zitdTtC;++;CW|wLC^GA&|+|IPHs(6N*VD#WU7%+G* zQ&kDYjJUQSu@zwyN225Ftos8i`bP)-f-z?<9pi^C-p>bg4)H-WgC))jnq6Jufa`xn z(b;eDcSPsI92Nuf2}B@VFe1`jJtMJJmLQS8Gig3yM6#kC;!b$INHa@H>SJtnvd)a@ z+{HKGOgMgL4Ar$LAB{PxQNm*)Y09sgkg%L!7VP%aJG!ojJbbjCU`vtDaKo+x@rPhOZEJGf_rtokufo?tSTk7 zWupxxa9b?py;h*Vj%juY<6!c{H|TsT zW1Kp4Nro?BjFOv0yyQ=Mlg>Buo6&+qW1_X}$XdZ57}NX-ZgYl7-^uS53dT4!DPz{RH@39oTLgZe zyg*@$P`1{lt2BN;Jh1o@t<^}U!(B#GtjiF^>;qPsl1532%efU3r>W93z|V*H!#aPE zF$FpH?B48Or?D7(K(?VbBfNiaMk$&H8eIHw{)A8him5Z(6GhGkg{lJ$qE>y1?-evZ zU8u9@?z`(6VqGoCj3E=mXMhxy9EeOI$vwcI6*!;6PF0H}1ABd5=ll7L=$_7tx14C9 zkPD`cHeW+Hjhgk4$meN(7`E8CYsa?c#@!l!VGN|ar{YH~$a8>vb*z8K!v3PQ_9bi0 zg8PcK_EkiJaUv4WrenwCjcRzS8BE2VRw^X&)JpD^%!ZZ~zM=C4e$w&^d4+@eQ8a+& z?{)Z_{4JeSei}xtjYofuYWy8oGjTMEG2X@Bv+_RXkMbD0{1iF~Glll!ht@iVj@cs= zcV&|qQB=`i`_2 z&t?qEvOkrViu^O3pA~(FmJBCNk(FhGz0JkHF@jxou6k69~=H3eys9KXk_G_ zLu1@b8?O@AdGUYVk?euf<%SsTWIKD2hje~fp`v+YcQ?!$RTTxPBpo-59+4fk0bH>w z4qdS+&O%>b05^}z%Nj)kWCX75QgnI{?y8hS3;AD%T*@R2Km39+83vBWIy7Y}I)V}* z&|sPwWQ%Z*_{Bz!=a^zwsES)xJRAViFk>X9bJ}$gaa(+^8LKPd6?& ztu63!g;J?2K4qbcTCBIlLY4!?Kfg?XEwh2LL|0}jRVZI5C?fhSqm8|jvQ}~6GNoEr zt_Fgn#ZP}p@T?P=B6eq2O?;kGtJDef<#1(KtTx{($HUoVq#OOZ)%pv2Y064rAz13P^z*KNivo^W*$WXT3=$2ocL}v^etJOUQ(+mn3tT$u_)+ccrBry61*1XBxS48 zg62WlhGLNKhsC|UrUb<=j3q9oM%}I`ZRi4&9ZYpTxET13`i_TV834)bKU}MQVVR+P z8B>22g8-;w+H#75FWxa?mHT38U)K6@MN{?^<(84kqwE7uBkIEp+YKdQR`*%Alh8_t zY1yUk8;4U>K8P?yJ*!}fs>zpK-^lc5l`f(0kx5w2PB;jI)uu)yaV$kKNTw38q~VJQ zKkPwelk(@2nQvP-mQ8dRDY=3a?;ur{Qp6W&_>Yw?qDe>aR!*cUr?zH+$^#EP<5FvhoedOLZNcExC>Krxo)7F~cvg*S3cKmn}J+*M|-sZ0o16{VW-dN2od!vbnq3?e186juP(bvy?8ZX0du) ztnMqU^kU^TVkP8$9RS_0KTB^IptlUt?V*5uknRZi&(OPa^xl5DtDinFNFNFX9Dc98 zpYC~xKFJhtdYuo^XPHj(d9OpfpJ9J`45R~Ujs{Ni$GxiiVId|>8>BA)SD>Ej8@hn? zFXregr^yR670P+Ss~*nLg&aK{aP$q`hyCx!{aUdRrv02zPKAhlP^ z(Q~J1x}YWA3%pJB=V=GZ1XP)XdV|+7NY977Wry7_^wS@6^w%8yUF=rdYCw}@wIZ?>GZzB@@oE7O=o>l* zI~m2yUKFQ9Cgdv*&>%2!>=1wNYrJ+a#o7Q*ZX2Xi;JlxwxO;Q#KEpF}JbT32)KX+? z56{o>6`?iS-84h8$%wx zrk}4pXT3Iv*9UpaJ`cAHa4XI_PZc7xAd&+(UMJ)yzlV1W@U97Vr^potsEE+?hs0;K zhj;h$z5zZ28N`CuQMAH`Lv4`JokcViq{GX~e(uPzaoTo%kh?;mnn9i$>gVo$K6-}D z)ob-;Mac~?&q5Z`Q}h7B5#my1xZJBKcDpX^KF0+wVmO&3HsCohCTfD z9KS2HM!j1&_GGWK!qU00org~q_H@Xk_R%D-(^jEM%lJbeGr;f7@m&GU!*>txJ)uCE z7q1`7@h5Y9-yq))KeDgUa{OS02A0T;62jE=dbozhmVav?|s<5ASh6h0i zs+D;__c{V)eQ*=3JR(+&6O{ad&>4Pgm=>H<5`#_!wX!q(f^L0zdFNl=iRh*ke>_5`1(@~IQVmp|0W&jU!k_gX+9#|KAQQTvBUud%Ic?IQ=b)|{vIL1lL6U=R>< za?1Qx`y(_jWUFZ(P!{EsEBlqD1BxFfuka|Va>`olmWP5i_r`XQvJT5vV?o8jvUbK- z!@iu-{5gN2Ho3grRt>N%%LbI~LSy52=eBbN6~i_jrB&MIcR6LJN7*HeTvnvXXWK_5u5O`MhBNk$8VPr#t63PY^kmIakQ%T4z8$H#s-U z=VoV%vm4K#bBBEHc3v-^9nNm~yv2D^t;h4E^PLj@l=D5}sn)AO`P`xIlF!|0r+miL zTf~zT1=u!&Ru6$aMWu}@EhJXynjxB;{|4D1`Ut7khy1%X5gB>2(P`*SnZ1ZTQ z%}29ri^*$SO0#WiXpXIs=Gu1BJX?P^&9^0Kf$fdtv)x8l*uG7bwijuk-A0S-DlN88 zp)2ifT4JxFDtiqrwXddS_O(=PucsROb>z1nqFQ@|>g*?Jx&33b!rn(K?GMl@`;Ta~ z{YARU{x4eNU|Q=~MC%-WTJKm+0Y?jMaO|L~9SPd#I7XWsy>yM^eRQqk0jfH8PNxRv zT55E@hnk#sQM2=hv{~IuThzDFR`n@rQJYt%27H$Y#+5QbsO9u#{)Cb{z761TU zEt3I79Fl%9e+hV8RTcj4Op^C9nQjSbJEgQCZ6QrFNf#Q*0ELpa5DfvFmN2vsUuRyD zS7zpgnKx~5K}9WYD2rPW7u<@93YbnJks@MSK?QLKQE@>*#RX9jk@%lGGtDGTBKf|_ zdFS49&wkH2_o0{XIRxM|)vj>MHP>ue_xk#sR_sbUe-*Ef)W>@3o9bh3a==Mgp5vy% zNjGkDJ#8m!D`RuB-^zqz{dVliOg5RRkMvrJjNMc}&=*cx17Sya#N(%}S-o}*Y18Y9 z=X1Q#;>R(KUrJJsi;Y&-3w`nbB=PG=~K>+71=G_MQC?cMcnG@%p%U2ZlVvo|{l zTVbJ_f9`APOIz`T-LfZb4Gh@nmiAP}vl5A=s|=JW%-&_~wptQas;}juoxALqXP`pi zB)yvToJ32^O~tb5w4L%=+IY;`nXnC*JhILmzY87QxR{s1lmE zlkqk>X@#01mUeb##Z%kTiDQRSw%4+4OFIwEe-ScD?REOHY3)&kze;d!hz;axx2} zS(vrZ)8d0vTp`?WJmK+Y3!=zk6;_M1H8j52z0$;51=Dl$R6(3B0#;z1!jefNI8KUo zT|^X;ymT_mNIJ?*U#%T`SrBJqz3iSte|4RVa0y~Ve(5}gSu}RT&WxMLdiKSZ*B`{j zymgxt7EGNI2F~Y&v|=$k!;D%NStFSK7$|@9GYoU@VHB(3G-9N4y?y2;g;iBS{ln5%F}|oQCDw zC)SKN;msoNExaTX_6)qW7)s50Lpp6~nFih-z&KGX^d*aPBx0+6(Jc?!998;|{8H4zOw31!8gAKrQ zH*~eNw-@W@m!yQb_%i*+al+}ndZW81m2j}O})+; z=#V*Ks)Rmf1`i%YP7V&Std0eY3|cs3Y}y;M2l99BtNH$uFU2Eye>=X$wdRbzd?pSN zN!u*gyIF1Or*1pN%M`@daldf+2E9?#>bz`kubsBzTWm|WzHc&W#l7~_K(#5*`de+Ka*aoJ(~m||lIH^Y^m%3N_6j}>pM7E|K!pN-qt+Mjm!gxry%|;?)e4&Le<<% zbBa@riNA4dkd#Zi)Zb$bJ>?Y*GnD*yJRe{m{713o=gXMf2)gfI3chV!$2wxk9#8%o zFIM6O{D-1Fx5M4T-oqEgnCMdKNk#t`F9&cHMrp_%Clz=1WK6|3g30mPvz!!5`iZ4h zwDnu*F8ivif1Qfys-pa=jOSH3{j<|a6@q9gLt*~dDY`@koZ^J2DkZD>`HC@B6${zv zYuB1;291~IYo*+jLw)tlRkQRErDjV7-#$fptLlIXs2cL*q>}ceTa=nw5PoJ*)vCEd zIgc0ZxNSp)#08e)ZI*t)iLX7VPE-p6YJuWtJ(H@Hf7~?Qq)Vw#OS}H%j36KDW-vP@g)!ES-2A+lk(5 zHq}N3s*TTWD$(WfMSr0+uvIkWFe8PsGn?FLf2Z{dA8h5E3~4jUXU~yG8$cK=Kt9+s z02!d1fwu3!*t}9!5v>!a=+y+Ia*O2mG^E+>LHB*`9-yL%h2&8r?x^Qq1oh z#KK4!k44G{u_zj;Xv(3#dl1Qp;cqo7S}VhvyIE`QN1!PjD$5}oD$il>EvOpCH4*aw z+6BKh8ZnPj*66b#a|HXMk-!kHJJed`e{T)e25YN6iNztaHn=((nW2@g3I#&^dUyBR zg6hENlc7Mw44GfWjSBgX4=U`(8u{9<*tVCEANBvJI3yJ4ss8v7ZljrbU*z!FVSK*( z!03b2uVN5i%;C;($QZ_;C^k$p4&XQ4wUrgO;gOJW6c06Ns%XT}>m4)=<8fA1@D zd>~?uXsIDH6bKhW5zbStETLo^=#UW{j_!~XN24QnkQxr*JJk;l;n5-dFo&N+%p4vM znGxdvI>lj?Az8SuDO$A1=&62^77gQfIXqMS$75y{_syQ_XSKzDJ+`GHMp>&_Tj_gk zw6*eM>dad6mY2JWDZt-C&Fs#Se?(AKvK@_-Nr0=L8^%BH#!ERSukz(o#eT*PKhidr zhijBc!&K*p3PdaJ#Z}R0sJtiYuTjCSvKlqBtGu-$r{>gF^mGlW6LM-k(%l8Zpc6g%OQZfBHj47u{W% zQ!5zECpr&cHh&9*(Mo>I4G*iNhL7OnP+8GU2fA9M$k4Jgnj49Eb$Ue+VP+X$~0zUu0V*WWx<;ID>smpmZ96_38`_&sJMBOsWC( zB%V-Lsds4jE_Ji(jc&i%L@N4Q(4IfoMR8Ilw$LcYSKc)UC(09G>1OAz+MZ7pLgNAjzs>gPGN|Od&uulVHIvORLe{7lS-U9M#HTF z6(q2w8!clSWu+V9^8CgNIC+#Ex{Q6gK**6&zB}`&bZkRWV{g8_7l@DXYK{Zlq}$H@ zE9l2-ISlM0-Az>NvuyD%A)q#(N^L|?^aw(oMx@x@T>>qCw28l2$o zLaqM_%=O1H&+lNqKY@^cK+Ey#vBUpAP)i30lY;XFllzKjf7e6n;l{gF@YHqDRwydo z2%?|}3WAq$ce;&c4B_ zEHh6P9%0yQe{60wm^H1R`F2-p7Hmg)8{AS7sf5U=Bx1Ek#`0OLx7Hi$Eia^=dp`mp zP&rS#CZGeQNnj~8kslcuYVvQ5%rY|mQDSqc_2PHlFD_Qbpup6%>`7nCB=S$Mt|`dN z7-qk(@xwG`zlq~Mqf)={-(jIGmF^lkA!}vCMD6(3Zsj~LZp+m0u1ZwCC$O;m*Wf?A zav@M!Ub%4KV4{LDCLN4mbQD9VI;dc*sHO!5_xY7j<)+L(Gr$#7TvZE(v*2(r&g(39 z^C)ouldG4PFPK_;My>vgnJ1u+miiW@Pf$w-2x_|O^J)PA0OtXd0Yw~>>x?jecpTMr zKG*x0)oT5aWZ7P9@L002w5yf;z?PADNwNW1>j#n_tnFY%yCZ4v?#{9^YgxPsjp+m0 zrX;k9odzf=6>Vr5w`OJHfT2x+(2_KLr=_GVNgoMmQrfgNEo}dDXI9#kB}iL;{`Stf z_uO;OJ?B4tU|_W>K@V3mfqf!8;xbOT+Cn@snk`QHg4Vo-u%|` z{*gjDjR|W^i){d@XGe{!uIG*HC}xlAc?)M@erw03j;*nje!S`400}{V!6CDdPwF=s zXmbFXEYVwq8 zD>oZiThC{;bms^dJJV)=@)$1Mxnth#5bnRm$Qt%_f4yhAjs3&b|6HHXi1P1suQ&B|Dm@+4MAE;bs-AT!W#0?vJeHRhQC&XC`h&Zbs5~L z$z5yLuU{`{bj}O94&4@)&NR$UKFp=0Ylmz`&9=4=*u2&q`xvHw?AuY@?n`TyC8(jb ztwNTZ+!mrMXf<0w6%?vGR-q<1L_c9zwj~XAC`44I746A^1>ss3mS6d@Q>uCdP zu~E?CS!)Ucn;K?+MEB(LnmkjXEkWvHPuCjOb|VkX%=|=%u68cejSFfipue#-K0A)K z@x`y9Yk5DAxu{xkg>Dd}7}gHHU5I+ArIvcAPtff*N$;pBFy)Qm0$V~|*J7JdI zS<_aNX4ck>tg2-vz~<;==vIfi<3tXGo>Fa79Wk;gRX?GBCGGTtx?!4cq9Z^%;GYpQ zpV45_t6MKc$>BNfaw%7cZlarm)JFY+*8PaEQfNR>bL)q~RL0n@AjN67Ag^WIrAs9B zhiEU|!iE||sLyLC*FF}^V5*t_tCjZQNQ40Uw!iICi-hO^9b{E*1z*}24$vV+1oUm2 z!x+7$X+uqaEw>Ab4cS^AsbcL0g+3Cb+ZbJK)i%j$8O|3rXPr4F@aNne$WvD5}$V53O_PGU1(B?T%^5ISdz=v+`iEZ4xB|xJnC6dL`lZCut zPjv1=PD2{pZj9<24hBLD=9Xy5CgJZ5bDZh=VQv|JFwHSa2k8!i#>*?U>(Ay2Hbm%J zMj?}vL$&e_-tG)ij!=vi9PU-fF6RUARBb;FK;jEA?`u8W%aA-l6G0lMyAV}{TuQT{ zyMm?ueinNV-OC!?R~9F4vu`YKj%&l5EANM#WZJa!5dAn;m2vtgKUuz3g-Ln~Mmoi{hNB+^N!FR4fo*N`X8nY-=MqRyNA%Cp$Aa{; z^z+;Rpxdy=LiBOEg@gPPm|`qtaq(5HeV6Wb6@idnpkHKNJ}D?RzYFKtd5U+QM)9%D zvaU;8=T!BV=rhdw7}uIR3+Sgp^aLl{Hu`0MHXu4L8#eu{lc#?LDIehK8Me%H!PdF1 zhv-*XLNiT@1^xq!dm|~EH`N@OD?-!}4Nys~Y00)^6X>tzX>$1SBG^ytJ+!y zv5!PEZrEcTE!jRZJ7VNBsy(LJ_|esMm79mgG(^f!A+t`+xyCdi5JYdWJraHpBrRx`jD1 z%^?JJS~fF{(;Z4Ruz!nwn_+nt8Doxhg^D5i0-Xt>H#~>jQOMq92}*zUsY*q*MeuhLhT{WVmiOSIkrH76AM189th-i<;T zqOWo!zfNC6#+kPt=a}D@*Z9?cq&dw9XU4Cid9}0=nGsl)peui*oCPKSnEoV4e?))E zC!-JaXO5wJz+L~sNjcv@o-8||w=gooiC|B`uBaq`C1^#Zo2pm;I!JG_U&1qX9 z_cuX$gZ>uXr7WG(tAaXP<8zy?e3|OHhWorl-(uH(8(x{~K!yGRa2rQ|*@eOXiL2T_ z(s%ghqr3}6D=4AJDIy)BFVcBN==UqD=$?u|`WL(e`pg2tl$#W}Qw`9+az;l4c{%z6 z^zVWMg7QCMgn1uz3cbtympK}u|KJzfjO_4|ED;T}3hunEdqu$&jWD@b zCTQ)Do=0q`dEGALvq59OA1J!4n`v>C{CUF+y zP;HgCJSbL*E2_7}6@gddA`~&MiCO2lhtxZ3|I8XBHHqe+SR>XVC*Z}^t64^}r-0Ic z6zvqHnI^hynfZhvbi|cn9or0V&w2nhSxBRA+i&Ulo>52)i3nhVoOyKei9$$1EUGivEz; zEVk4@r!G_#oZ}un&Eak3ep6g6x>*L^?~9}|TFT`JiEEvu>&i8b?{G6}@vM8?;Pgs^ zuIu~Y`H<*E7bto}A2)w}q`S$8RF8yx>DPkBky4(Tc#c3C;zA;=>moJx{I~gn~p$A1$ zj3B{I_ju!)r5ZE0?g)r6s6z;R3W#IKt$F!6-DieGhTD*4fyk??%x|*23I`Dfju8bs(Oi}%LTACP` zqQ=Oxv^@GOh1;K{m1m^yYiJc+?rajTVv8SRZ8TD(H3y5d?lc9@QRl!UT^}vdro_N2 z(2LZJ z`Q}6-9;rV(MMt3QDQb<%^VdYr(`~HaQP9JGiTKO3IQoM3395;DHcpaPyi$2Y>XIWC zN+KdaM85zNEf9C%_ikEPf~h@h={BMgEakyxvrE;IN1@H-wT0vZrBD}W6lw~W;16c+ zav21(D-L_hl_lz7y4j&`z}H1uU4m~GU?vWa+zkaHaJU~E_hNPo!j8jRAA{J(Fgpo< zzPA93cd(}fz8cbL#Puq#GjzV%{t9`|)Q_E`?C$fFOLTjqQ)JaGp)UoxePJ)V?C!)C z|6^1i3;R5c{v!R@B-~A(X!I|5oc;c0EbJ}P$s+v}_CJLEQ}nQBi?7iad*Mmyh&B2) z)luobbM#1}8=D`6!E3|bCF_gyse=%IkEu@|Jm~`>zTVDq9#8Bp(vzp4QZ!Mdr+~Jn z;|hBvairVpi41w8L%#MQe{87!*TY`NMb9MQpx?Y8wYUHaG}2`-IRU}Va%{uz=4pq0 zoPxghX_-QID3nvEP@)wi^BqVM3O!I_^TOhe6Q}v$u8R~XLAt+Uv7n$95IQq|CS3=+ zixBt_`*aa`D>g_scUGS${kRC4`{2h%aQtiduHi?J8@Br;Oo+BbB#>hmo@M;5^<29u z3M;Q-$VZ~9HUjbI=(*G6^E`8M0c`p$a6a`6b_#j-h2(jU>J^$2D=tE04R^6F9G-SF z$&=^l`9xwDEc!x`eurc96^_w=llb_3f$(}gv6~MAN@7L&!*ld!GRXe?6fI`^|K-8S z($^;GaC_`Ly}_JsCKyCh^v$quivF%hf8Xt`^Ui|Sr)hB+THl>4eJ7T1@$@$SPnPZ< zh~T8RFSHlwduRCP09SEfv2a7l~zbxJQJo|K$lLMZg#*lA%S)tcC ziow*x;F+F%L!og%i0EBfQNpdfQUKV#MLoa4QZN=J~m*d9s9tUC}biW=v5WPzdxLSTakIbtPJo;v8B z+Ky8f;nZ_tX;CaME3|SqdmBYY)G(SFM7Y~4x_zSCFZos{x)nx$R(F7*1(1G|Q6*Xu zfEh5v{}b)Nm1rx9_6E^$v?#7RE4CKJHS+iRmqgDg+8Oq}D0+%wd*a$Udi4oTW?kp$ zodj#O>L{ZXrnsp=^h52i;wU#Ic3v0=1Gba&eRnK|eTkyj)9tTo1)z5o#o!iiO;=4# zS8dqeE|Kj+f=r!%6So${;nTEtS?#i#M&E-+x@xp8d}{buDvo4o9{mi3men?TAAIyQ zEsrhZNxiG)tk5vEthOjd!+~~BBVy&dETOBmt7fwF1nb)%3|1=|4ut)&v*L~hk%kS+ zp@X^?h_byS@QHcw4660!f$}xsoCa}c`F;(;!e>mH2=^|3IPQu}i4zwpCBIAoPS+>H zKK_D2Z$~cB8bpIBCPiG1p9N6zbg!g&WcpsZpS}m0$8Uo^NuQH6k4%4_ijwA$>6hqL zN%P3`SMbX;k4*m%Phh5bWcod^K+-&d79QbeT8>OF5=$k`BhxFz8cFlWbeGsBX&#v# z6#FI3Bh$BkiV;ck$n>4!9!c}a^wZ)S@}4p`2!ocCpgLMHp@=0;85mc@8jfltfM(gG zVTD6|oXW9Y!dK;jy8$BNa!F>4X1T10^;HsAP_47d#RzTn%yzGr*Slw}i>md85;e?q zvePb996PNpR#wvjcSZI)io4xOXzqPHXgeyWA=kY>4%%#tv)3SLJ(t}Fs$>zX=a;io zKD|bs;C4UtNPo8=SKSKpKSCaqF)ue!><;q$4^T@72uXpH=MoVB0Mj6o0Yw~>Po6b@ zp};~Zlu|$tP+S$;!m`{n4K*f)#Dt_?Vhu*VO?QXw!rs^m#u)h_{0cRSi68s{{v!2* z@eD0Ou$7(cX7-))yyr~L%=h14zX4do62sBq;q&rawa$$_;hE~XYV4>Bs^PnV?eN(4 zJZyvq-`?r_i2pVoJU5i96r7(G)T65*M=?g#~a3_bgQi7jFV zw$0Fc-}dbI0Yi6TyST-WDipUe$Y3Z91=$SJ80be2ad#d@UOd9@fNuB0NJ>iq&?1o3AkFmm&WYISWKC%mY1&Jal%>P%mcu=C(*W{Khe7EuJ#&o0 z1&<#@oq42AJc^yGm^#M7f2*JiL@shU^#@Q(2M8yw0*RB}pjUs-O2a@9#%E3c8LQYQ zQ1;YH)1a*ost6)@5)_5rx0`9Q?Pe2p(|8d3Aijks!GjOrLx~g7gR?Ln-*3N}Wk0{( zKLB6?dkkJSoBQaA&xKr}iTRYv1s`&mXNA(DRJjSVJVxRcH42AxnF<%k6y?gTGsmY3 zp&br+kp!720#$$Sh~vrl;#AsR)kAqDhoNw8|tzE3}T@A|8##qbP{6 z;?Esm4E%?DZ6#hSjSLQRn}mrKvBvPxilRUp-ib23bPlt*M%#u4gZ-tbM5u*H!rS>0 zW!Z)ngVwn+s=Q!u(7*W!s64E)a~FCS!UjmW=6k)iF#>7`BzF+C@%smz!MkI4LWd zm(nX-e_!|fsu!CqX{N`MF{hlWYEH_K7{%hm_~k3(Wb0=3{7b%RlEABIsY`U^R@tyP zcMYpd(hcr<6pQ4UvGK7?s>nBDKZL*-)ST_RI=^k0oFQ(z<#gHAiY8BQx|-u~H+|Q& z=_L&ANt;>CBBiUKgQ0s(+tAXcW|h;6g*C1Ve+8WkXUbgUwmiYBO;3gk@oZpi*l7tf zHL`Q`g<+=WHD`(;(yCXWGISc=PFn5pk^2!u(4``blMH=L-)Y-4DKgdODd=Vh@v0-X z2$A7*{BV#6dT>U?Y4ly4c{~*F1IL#T!a8=Hn`7NJV#$4-FFlcefe$s{l4r%bNEoLvxBMFVnwc z#6?y9fXl~{LI05-7@W?+=gjZHg>5R#(a;vYg41G>K6g-WcqJtLGV3f{hPm|@eq?l`Z zzu1B!vN@~L7E^OFbkTpF!iOfKGmveTPDO8rwn9?m^(f_`y7s1OQ%4|}qiht8CaVnu z*T%r372-v&2BaYwWp9VG2Y>R{GpNJe)QX7&b979pmUL-0+z!8^v_Pv0R}9g-6;tmN zN4q5u20y$PaE>^ch z;P-}nlh4s6N2hM>|yYssH!>Zj;G&PyE~>Du-o6#Z)EB; zDtRzt+-z|E1=&zVKNVmKQNW!%Q>gUy3QY7 zJnf>qOU^>~y@zct94{q=UY_OD3d_S}tBjm`uj2jdVgA<*ANubksr0Yif;@--YT~SSaO>`~2N;~~yc&wyzYt94z#l3zWdf*xwb2v zA0BFm4i?rWQ55o1KY0weXm&yJ>D7ki=;`&x2M>wQbU#bcCM3a6+>MYoohS`RlnA1| z2w`80-R^mVrpdzy-t*>jj0d11%~KZYslsU#Z?KUO_wN_gdx0wg`X*}yIDhhnQQ3Pq zInSI@3+L&TA8#HpWf-4x^Its5o_sX+&(1-&G3advRfI7c+s}!U%h9X@nL>t!rd0xc z=XF}GP-dQ@P3dJT$j+ds(z{u7QBY5GaRSsrS$fqRWhG|v(M8&{Jg02f$^ft6qLBU2 z{%!8)+s4q+iZXde3lC3**b4{*r!yu$?PlF8Iu=~V&xz}9vKbGqXzjCuGzdkSYk8asfJ0j4(e1Ckj06slMs(&|KCRCiCw~Q!rlUD%>s&^SX z{$!XLniozCS#~q{J##OoBTvuf{6`9FV?zw<({^TkMNKRi#aoWf-ERXNoYqQVmS^QX22+BLQlSsuq=*|=|S z#XjC^NE`^7ot04i8t-l!`ig6yX)j+`6+e^QvPHu-5Htfw9Dd?@;=a-V3Vj^>MT!kgbzaQwl$~7lmJ|a&A^k*2c_j zQ{#{+J1Ks$%!!3Np&BNxhC}GuK)V5-EV+hWS72nO@_N@ur?QF@>vuPoI~Ep3+=&q1 ztrnX&M2D_WjoVIH?K6Gc(wW&B9rGfZTbB2xHQ+ekgs#Rs4~4AL^BDa$kG2};+j{QG zoqGIwcO2*r3+-fvL$mXJF>&5=%nDllPnD(I-o}v2F@u|CN28Q&v3_W+$PC9RvLLgI zPpi`n*Vq+bj-*EmAT*+H_t&;_*{lP3|6fy zdZe&B{V?PD0+bAlfzqQOUvzYm4q0*V(9&TCW?RTap7{8V$`u;~UHi2Saxq zKx7k=r;-|^Ks;{oFJjPSds5b+;%?Mt-F%Lsls#avVpqkAlP4Nz_$@}@G0Tv^Z4r2~`^S~@B6KZW5QXHycQ<)LVW^#oKZyTz!u=Iz%- znKIsjyK5_Xnaq~UdM7s=GOvB(Or(1?YUO28|Iw1atTLVXy_smh5C`F75$0#36~c)x zyqUtN&vHQ0dTHZV9VAKu|+Yn-!FIM zsk>mTeukp5&*%%fZFy>9ha0zYVy^zPr>~`5t-oz`CSfeGBOWpWJR{p~CPa%*?6iw; zAudKg;#4l_Z``e&O@gJ zZyX6s&1?H_X#s%&inBAN+8Vokiftii=WuhvbC??3GAsK|FS$uwW>W_OwlHtCB#H%Xhw0FkI|^(oSet-(A7 zzp(VKSlYy+d$LBqT3C0CeE3w|l#)@tpY3M<^}}lZp++#(!2V700V(aHkkxf=*=?zy zxc!9jm&W@yVI}OWWg-u3m zioLs+hTFpMyukPQp7t~sXz3fww76a6It|U}Q<6ua(A7PD0xf!zXHnMPJgN>2t#;s2 z^rkALD)e<_qdhpe*E1!y8*)uvxdW_VPM1yj*rJ+tAR1ck-5Q_c+p5L{@nT&I(+x&eB5F4LqeO$(&g*y*MqW7DVt4~d~7R4+`j z^8x*;QVN!N-lxEBl?&yeZNWkkU|($sBm1fj=Jekpb9_V*J**7H@8Dhl zjb$Y-5FpO`B0z|OZDxf1$%iErRh~qAUHCtc3Xl}xB*MRwK;ZTO1Nq~_gs_|!t;K@2M7%@?h0CW?MkQxb;DM5sM>f~U@xmzHR5D641MS$SId>s$h zaemIbU^d1`RHvZ#x0%CP1nr5E<~QfgiZgC+!S1b93wt3j)cIsL~kyfwP*Bu>Us^<0An>F8v3x5fzW^r$8Wa67abd z5zKJpCxXX6`hY-UB;c|Q5o~N`0(4x#MELh0xz}_AQ!9252u=d`+#`Ws*zx-l2qZzWmT@K#(r=T29k*crK0FIKM5v-o8b+)ls6ZfXdJss2 dL`goE2uYNj1UTAx82CVZAVvbDQ1ZK;_#Zl>@t6Pr diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f78a6af..dbc3ce4a0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index adff685a0..0262dcbd5 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. From ae892936905fdd8737101b78043ba332a0f3dbe6 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 025/349] feat(compose): Add date separators and jump-to-message --- .../dankchat/chat/compose/ChatComposable.kt | 6 +- .../chat/compose/ChatComposeViewModel.kt | 46 +++++++- .../chat/compose/ChatMessageUiState.kt | 15 +++ .../flxrs/dankchat/chat/compose/ChatScreen.kt | 100 ++++++++++++++++-- .../chat/compose/messages/SystemMessages.kt | 20 ++++ .../chat/mention/compose/MentionComposable.kt | 2 + .../message/compose/MessageOptionsParams.kt | 1 + .../main/compose/ChannelPagerViewModel.kt | 19 ++++ .../main/compose/FullScreenSheetOverlay.kt | 17 +-- .../flxrs/dankchat/main/compose/MainScreen.kt | 37 ++++++- .../main/compose/MainScreenDialogs.kt | 7 ++ .../compose/dialogs/MessageOptionsDialog.kt | 14 +++ .../main/compose/sheets/MentionSheet.kt | 2 + app/src/main/res/values-be-rBY/strings.xml | 2 + app/src/main/res/values-ca/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-de-rDE/strings.xml | 2 + app/src/main/res/values-en-rAU/strings.xml | 2 + app/src/main/res/values-en-rGB/strings.xml | 2 + app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values-es-rES/strings.xml | 2 + app/src/main/res/values-fi-rFI/strings.xml | 2 + app/src/main/res/values-fr-rFR/strings.xml | 2 + app/src/main/res/values-hu-rHU/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ja-rJP/strings.xml | 2 + app/src/main/res/values-pl-rPL/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-pt-rPT/strings.xml | 2 + app/src/main/res/values-ru-rRU/strings.xml | 2 + app/src/main/res/values-sr/strings.xml | 2 + app/src/main/res/values-tr-rTR/strings.xml | 2 + app/src/main/res/values-uk-rUA/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 34 files changed, 307 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 68e9640ed..cb83f826f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -43,6 +43,8 @@ fun ChatComposable( scrollModifier: Modifier = Modifier, onScrollToBottom: () -> Unit = {}, onScrollDirectionChanged: (Boolean) -> Unit = {}, + scrollToMessageId: String? = null, + onScrollToMessageHandled: () -> Unit = {}, ) { // Create ChatComposeViewModel with channel-specific key for proper scoping val viewModel: ChatComposeViewModel = koinViewModel( @@ -79,7 +81,9 @@ fun ChatComposable( contentPadding = contentPadding, scrollModifier = scrollModifier, onScrollToBottom = onScrollToBottom, - onScrollDirectionChanged = onScrollDirectionChanged + onScrollDirectionChanged = onScrollDirectionChanged, + scrollToMessageId = scrollToMessageId, + onScrollToMessageHandled = onScrollToMessageHandled, ) } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 66fe09525..e012c6d90 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -12,6 +12,7 @@ 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 com.flxrs.dankchat.utils.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -21,10 +22,17 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale /** * ViewModel for Compose-based chat display. - * + * * Unlike ChatViewModel (which uses SavedStateHandle with Fragment navigation args), * this ViewModel takes the channel directly as a constructor parameter, making it * suitable for Compose usage where we can pass parameters via koinViewModel(). @@ -48,6 +56,8 @@ class ChatComposeViewModel( 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, @@ -60,8 +70,12 @@ class ChatComposeViewModel( lastChatSettings = chatSettings } + val zone = ZoneId.systemDefault() + val result = ArrayList(messages.size + 8) var messageCount = 0 - messages.mapIndexed { index, item -> + + for (index in messages.indices) { + val item = messages[index] val isAlternateBackground = when (index) { messages.lastIndex -> messageCount++.isEven else -> (index - messages.size - 1).isEven @@ -69,7 +83,7 @@ class ChatComposeViewModel( val altBg = isAlternateBackground && appearanceSettings.checkeredMessages val cacheKey = "${item.message.id}-${item.tag}-$altBg" - mappingCache.getOrPut(cacheKey) { + val mapped = mappingCache.getOrPut(cacheKey) { item.toChatMessageUiState( context = context, appearanceSettings = appearanceSettings, @@ -78,7 +92,31 @@ class ChatComposeViewModel( isAlternateBackground = altBg ) } + 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), + ) + } + } } + + result }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index d61312570..707829f91 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -129,6 +129,21 @@ sealed interface ChatMessageUiState { val requiresUserInput: Boolean, ) : ChatMessageUiState + /** + * Date separator inserted between messages from different days + */ + @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, + val dateText: String, + ) : ChatMessageUiState + /** * Whisper messages */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 7296e11d0..a875491d8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -8,19 +8,25 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +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.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface @@ -34,9 +40,11 @@ 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.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.messages.DateSeparatorComposable import com.flxrs.dankchat.chat.compose.messages.ModerationMessageComposable import com.flxrs.dankchat.chat.compose.messages.NoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.PointRedemptionMessageComposable @@ -77,6 +85,9 @@ fun ChatScreen( scrollModifier: Modifier = Modifier, onScrollToBottom: () -> Unit = {}, onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, + scrollToMessageId: String? = null, + onScrollToMessageHandled: () -> Unit = {}, + onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, ) { val listState = rememberLazyListState() @@ -111,6 +122,20 @@ fun ChatScreen( val reversedMessages = remember(messages) { messages.asReversed() } + // Handle scroll-to-message requests + val density = LocalDensity.current + LaunchedEffect(scrollToMessageId) { + val targetId = scrollToMessageId ?: 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) + } + onScrollToMessageHandled() + } + Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -134,6 +159,7 @@ fun ChatScreen( is ChatMessageUiState.PrivMessageUi -> "privmsg" is ChatMessageUiState.WhisperMessageUi -> "whisper" is ChatMessageUiState.PointRedemptionMessageUi -> "redemption" + is ChatMessageUiState.DateSeparatorUi -> "datesep" } } ) { message -> @@ -147,6 +173,7 @@ fun ChatScreen( onEmoteClick = onEmoteClick, onReplyClick = onReplyClick, onWhisperReply = onWhisperReply, + onJumpToMessage = onJumpToMessage, ) // Add divider after each message if enabled @@ -249,6 +276,7 @@ private fun ChatMessageItem( onEmoteClick: (emotes: List) -> Unit, onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit, onWhisperReply: ((userName: UserName) -> Unit)? = null, + onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, ) { when (message) { is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( @@ -271,18 +299,57 @@ private fun ChatMessageItem( fontSize = fontSize ) - is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( + is ChatMessageUiState.PrivMessageUi -> { + if (onJumpToMessage != null) { + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.background(backgroundColor), + ) { + Box(modifier = Modifier.weight(1f)) { + PrivMessageComposable( + message = message, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick + ) + } + IconButton( + onClick = { onJumpToMessage(message.id, message.channel) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.message_jump_to), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + PrivMessageComposable( + message = message, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick + ) + } + } + + is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( message = message, - fontSize = fontSize, - showChannelPrefix = showChannelPrefix, - animateGifs = animateGifs, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick + fontSize = fontSize ) - is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( + is ChatMessageUiState.DateSeparatorUi -> DateSeparatorComposable( message = message, fontSize = fontSize ) @@ -302,3 +369,18 @@ private fun ChatMessageItem( ) } } + +/** + * Scrolls so that [index] is vertically centered in the usable viewport area + * (the region between [topPaddingPx] and [bottomPaddingPx]). + * + * In a reverse-layout LazyColumn, `scrollToItem(index, scrollOffset)` places + * the item's bottom edge [scrollOffset] pixels above the viewport's bottom. + * We set the offset so the item lands in the middle of the usable area. + */ +private suspend fun LazyListState.scrollToCentered(index: Int, topPaddingPx: Int, bottomPaddingPx: Int) { + val viewportHeight = layoutInfo.viewportSize.height + val usableHeight = viewportHeight - topPaddingPx - bottomPaddingPx + val centeringOffset = bottomPaddingPx + usableHeight / 2 + scrollToItem(index, scrollOffset = centeringOffset) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index 05d8b15ad..cef12af6e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -66,6 +66,26 @@ fun UserNoticeMessageComposable( ) } +/** + * 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, + ) +} + /** * Renders a moderation message (timeouts, bans, deletions) */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index 2a6d643e4..87229944b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -34,6 +34,7 @@ fun MentionComposable( onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onEmoteClick: (List) -> Unit, onWhisperReply: ((userName: UserName) -> Unit)? = null, + onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { @@ -56,6 +57,7 @@ fun MentionComposable( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, onWhisperReply = if (isWhisperTab) onWhisperReply else null, + onJumpToMessage = if (!isWhisperTab) onJumpToMessage else null, contentPadding = contentPadding ) } // CompositionLocalProvider diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt index db8025839..5811f0ee6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt @@ -9,4 +9,5 @@ data class MessageOptionsParams( val canModerate: Boolean, val canReply: Boolean, val canCopy: Boolean = true, + val canJump: Boolean = false, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt index 7456d80f8..a5bb108a9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -9,10 +9,14 @@ 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.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @KoinViewModel @@ -32,6 +36,9 @@ class ChannelPagerViewModel( ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelPagerUiState()) + private val _scrollToMessage = MutableSharedFlow() + val scrollToMessage: SharedFlow = _scrollToMessage.asSharedFlow() + fun onPageChanged(page: Int) { val channels = preferenceStore.channels if (page in channels.indices) { @@ -41,8 +48,20 @@ class ChannelPagerViewModel( chatRepository.clearMentionCount(channel) } } + + fun jumpToMessage(channel: UserName, messageId: String): Boolean { + val channels = preferenceStore.channels + val index = channels.indexOfFirst { it == channel } + if (index < 0) return false + if (chatRepository.getChat(channel).value.none { it.message.id == messageId }) return false + onPageChanged(index) + viewModelScope.launch { _scrollToMessage.emit(ScrollToMessageEvent(index, messageId)) } + return true + } } +data class ScrollToMessageEvent(val channelIndex: Int, val messageId: String) + @Immutable data class ChannelPagerUiState( val channels: ImmutableList = persistentListOf(), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index cd4196527..4b6ba7a4b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -36,6 +36,7 @@ fun FullScreenSheetOverlay( onMessageLongClick: (MessageOptionsParams) -> Unit, onEmoteClick: (List) -> Unit, onWhisperReply: (UserName) -> Unit = {}, + onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, bottomContentPadding: Dp = 0.dp, modifier: Modifier = Modifier, ) { @@ -76,14 +77,16 @@ fun FullScreenSheetOverlay( messageId = messageId, channel = channel?.let { UserName(it) }, fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false + canModerate = false, + canReply = false, + canCopy = true, + canJump = true, ) ) }, onEmoteClick = onEmoteClick, onWhisperReply = onWhisperReply, + onJumpToMessage = onJumpToMessage, bottomContentPadding = bottomContentPadding, ) } @@ -100,14 +103,16 @@ fun FullScreenSheetOverlay( messageId = messageId, channel = channel?.let { UserName(it) }, fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = false + canModerate = false, + canReply = false, + canCopy = true, + canJump = false, ) ) }, onEmoteClick = onEmoteClick, onWhisperReply = onWhisperReply, + onJumpToMessage = onJumpToMessage, bottomContentPadding = bottomContentPadding, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 7ca022624..61a0653fd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -59,6 +59,7 @@ 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 @@ -343,6 +344,17 @@ fun MainScreen( onOpenChannel = onOpenChannel, onReportChannel = onReportChannel, onOpenUrl = onOpenUrl, + onJumpToMessage = { messageId, channel -> + val jumped = channelPagerViewModel.jumpToMessage(channel, messageId) + if (jumped) { + dialogViewModel.dismissMessageOptions() + sheetNavigationViewModel.closeFullScreenSheet() + } else { + scope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.message_not_in_history)) + } + } + }, ) // External hosting upload disclaimer dialog @@ -504,6 +516,17 @@ fun MainScreen( } } + // Jump-to-message: per-channel scroll targets + val scrollTargets = remember { mutableStateMapOf() } + + LaunchedEffect(Unit) { + channelPagerViewModel.scrollToMessage.collect { event -> + val channel = pagerState.channels.getOrNull(event.channelIndex) ?: return@collect + composePagerState.scrollToPage(event.channelIndex) + scrollTargets[channel] = event.messageId + } + } + // Pager swipe reveals toolbar LaunchedEffect(composePagerState.isScrollInProgress) { if (composePagerState.isScrollInProgress) { @@ -745,7 +768,9 @@ fun MainScreen( ), scrollModifier = chatScrollModifier, onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, - onScrollDirectionChanged = { } + onScrollDirectionChanged = { }, + scrollToMessageId = scrollTargets[channel], + onScrollToMessageHandled = { scrollTargets.remove(channel) }, ) } } @@ -803,6 +828,16 @@ fun MainScreen( onMessageLongClick = dialogViewModel::showMessageOptions, onEmoteClick = dialogViewModel::showEmoteInfo, onWhisperReply = chatInputViewModel::setWhisperTarget, + onJumpToMessage = { messageId, channel -> + val jumped = channelPagerViewModel.jumpToMessage(channel, messageId) + if (jumped) { + sheetNavigationViewModel.closeFullScreenSheet() + } else { + scope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.message_not_in_history)) + } + } + }, bottomContentPadding = bottomPadding, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index abe40e955..bcd98fe33 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -51,6 +51,7 @@ fun MainScreenDialogs( onOpenChannel: () -> Unit, onReportChannel: () -> Unit, onOpenUrl: (String) -> Unit, + onJumpToMessage: (messageId: String, channel: UserName) -> Unit = { _, _ -> }, ) { val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -203,7 +204,13 @@ fun MainScreenDialogs( 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 = { chatInputViewModel.setReplying(true, s.messageId, s.replyName) }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt index 7718be177..54a5ff71a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt @@ -13,6 +13,7 @@ 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.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.AlertDialog @@ -50,8 +51,10 @@ fun MessageOptionsDialog( canModerate: Boolean, canReply: Boolean, canCopy: Boolean, + canJump: Boolean, hasReplyThread: Boolean, onReply: () -> Unit, + onJumpToMessage: () -> Unit, onViewThread: () -> Unit, onCopy: () -> Unit, onMoreActions: () -> Unit, @@ -95,6 +98,17 @@ fun MessageOptionsDialog( ) } + if (canJump && channel != null) { + MessageOptionItem( + icon = Icons.AutoMirrored.Filled.OpenInNew, + text = stringResource(R.string.message_jump_to), + onClick = { + onJumpToMessage() + onDismiss() + } + ) + } + if (canCopy) { MessageOptionItem( icon = Icons.Default.ContentCopy, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 5bc0b08dd..2f27d8ece 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -57,6 +57,7 @@ fun MentionSheet( onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onEmoteClick: (List) -> Unit, onWhisperReply: ((userName: UserName) -> Unit)? = null, + onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, bottomContentPadding: Dp = 0.dp, ) { val scope = rememberCoroutineScope() @@ -111,6 +112,7 @@ fun MentionSheet( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, onWhisperReply = if (page == 1) onWhisperReply else null, + onJumpToMessage = if (page == 0) onJumpToMessage else null, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), ) } diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index b295c3179..94875e331 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -352,6 +352,8 @@ Паглядзець галіну Скапіраваць ID паведамлення Больш… + Перайсці да паведамлення + Паведамленне больш не ў гісторыі чата У адказ @%1$s Галіна адказаў не знойдзена Паведамленне не знойдзена diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 716a1e20a..096bd935b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -301,4 +301,6 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Bloquejar aquest usuari? Eliminar aquest missatge? Netejar el xat? + Anar al missatge + El missatge ja no és a l\'historial del xat diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 30fa8f5ad..d80ecea90 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -359,6 +359,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Zobrazit vlákno Zkopírovat ID zprávy Více… + Přejít na zprávu + Zpráva již není v historii chatu Odpověď uživateli @%1$s Vlákno odpovědí nebylo nalezeno Zpráva nenalezena diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index d9ec05e7f..58b55e6c1 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -362,6 +362,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Antwortverlauf anzeigen Nachrichten-ID kopieren Mehr… + Zur Nachricht springen + Nachricht nicht mehr im Chatverlauf Antwort an @%1$s Antwortverlauf nicht gefunden Nachricht nicht gefunden diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index fe3c5034e..673b9f5c7 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -238,4 +238,6 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Ban this user? Delete this message? Clear chat? + Jump to message + Message no longer in chat history diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 77d10be54..baba179e5 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -239,4 +239,6 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Ban this user? Delete this message? Clear chat? + Jump to message + Message no longer in chat history diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index f2b6f9f1e..93df23163 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -355,6 +355,8 @@ View thread Copy message id More… + Jump to message + Message no longer in chat history Replying to @%1$s Reply thread not found Message not found diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index c6a7ed65c..20bbbf130 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -361,6 +361,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Ver hilo Copiar id del mensaje Ver más… + Ir al mensaje + El mensaje ya no está en el historial del chat Respondiendo a @%1$s Respuesta a hilo no encontrada Mensaje no encontrado diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index d803f9329..e97dd5678 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -265,4 +265,6 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Estetäänkö tämä käyttäjä? Poistetaanko tämä viesti? Tyhjennetäänkö chat? + Siirry viestiin + Viesti ei ole enää chat-historiassa diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 7b8cd21fc..b54ccb172 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -357,6 +357,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Afficher le fil de discussion Copier l\'ID du message Plus… + Aller au message + Le message n\'est plus dans l\'historique du chat Répondre à @%1$s Sujet de réponse introuvable Message non trouvé diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index baf757f7f..e77c8adc4 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -352,6 +352,8 @@ 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 Válaszol neki @%1$s Gondolatmenet nem található Üzenet nem található diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9a68e0a5c..c51d18c0d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -351,6 +351,8 @@ Visualizza thread Copia id del messaggio Altro… + Vai al messaggio + Il messaggio non è più nella cronologia della chat Stai rispondendo a @%1$s Thread risposta non trovato Messaggio non trovato diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 629c01223..c5dafb8c6 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -351,6 +351,8 @@ スレッドを表示 メッセージIDをコピー もっとみる… + メッセージに移動 + メッセージはチャット履歴にありません \@%1$sに返信 返信スレッドが見つかりません メッセージが見つかりません diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 868d64279..f610e53b3 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -359,6 +359,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Zobacz wątek Kopiuj id wiadomości Więcej… + Przejdź do wiadomości + Wiadomość nie jest już w historii czatu Odpowiadasz @%1$s Nie znaleziono wątku Nie znaleziono wiadomości diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 72813348e..aacc6f9cc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -352,6 +352,8 @@ Ver tópico Copiar ID da mensagem Mais… + Ir para a mensagem + Mensagem não está mais no histórico do chat Respondendo a @%1$s Tópico não encontrado Mensagem não encontrada diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index efcd54e41..93535b192 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -352,6 +352,8 @@ Vê a thread Copia o ID da mensagem Mais… + Ir para a mensagem + Mensagem já não está no histórico do chat Responder a @%1$s Thread de resposta não encontrado Mensagem não encontrada diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index b60bb4a39..77277221a 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -357,6 +357,8 @@ Посмотреть ветку Копировать ID сообщения Ещё… + Перейти к сообщению + Сообщение больше не в истории чата В ответ @%1$s Ветка ответов не найдена Сообщение не найдено diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 1a6f86d11..80c3dac48 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -206,4 +206,6 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Banovati ovog korisnika? Obrisati ovu poruku? Obrisati čet? + Иди на поруку + Порука више није у историји ћаскања diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 1329e9e5f..8bee83c8b 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -361,6 +361,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Akışı görüntüle Mesaj ID\'sini kopyala Daha fazlası… + Mesaja git + Mesaj artık sohbet geçmişinde değil \@%1$s yanıtlanıyor Yanıt akışı bulunamadı Mesaj bulunamadı diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 0b941ce34..5a17eebf8 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -359,6 +359,8 @@ Переглянути тему Скопіювати ідентифікатор повідомлення Більше… + Перейти до повідомлення + Повідомлення більше немає в історії чату Відповісти @%1$s Тема відповіді не знайдена Повідомлення не знайдено diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1069aa12..e0428976a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -436,6 +436,8 @@ View thread Copy message id More… + Jump to message + Message no longer in chat history Replying to @%1$s Whispering @%1$s Send a whisper From a6201118164931ca22f2a9ab11dc70dd4121ed3e Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 026/349] feat(compose): Add emote menu backspace and recent usage tracking --- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 20 ++- .../main/compose/ChatInputViewModel.kt | 32 +++- .../flxrs/dankchat/main/compose/MainScreen.kt | 4 +- .../dankchat/main/compose/sheets/EmoteMenu.kt | 168 ++++++++++-------- app/src/main/res/values/strings.xml | 1 + 5 files changed, 144 insertions(+), 81 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index a875491d8..c70ed1e7d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -374,13 +374,21 @@ private fun ChatMessageItem( * Scrolls so that [index] is vertically centered in the usable viewport area * (the region between [topPaddingPx] and [bottomPaddingPx]). * - * In a reverse-layout LazyColumn, `scrollToItem(index, scrollOffset)` places - * the item's bottom edge [scrollOffset] pixels above the viewport's bottom. - * We set the offset so the item lands in the middle of the usable area. + * 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 usableHeight = viewportHeight - topPaddingPx - bottomPaddingPx - val centeringOffset = bottomPaddingPx + usableHeight / 2 - scrollToItem(index, scrollOffset = centeringOffset) + val usableTop = topPaddingPx + val usableBottom = viewportHeight - bottomPaddingPx + val usableCenter = (usableTop + 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/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 809aaaf7a..e049d2c58 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -15,6 +15,7 @@ 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.emote.EmoteUsageRepository import com.flxrs.dankchat.data.repo.stream.StreamDataRepository import com.flxrs.dankchat.data.repo.command.CommandResult import com.flxrs.dankchat.data.twitch.chat.ConnectionState @@ -67,6 +68,7 @@ class ChatInputViewModel( private val streamsSettingsDataStore: StreamsSettingsDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val notificationsSettingsDataStore: NotificationsSettingsDataStore, + private val emoteUsageRepository: EmoteUsageRepository, private val mainEventBus: MainEventBus, ) : ViewModel() { @@ -399,6 +401,20 @@ class ChatInputViewModel( } } + 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 = chatRepository.activeChannel.value ?: return chatRepository.makeAndPostCustomSystemMessage(message, channel) @@ -423,20 +439,30 @@ class ChatInputViewModel( val currentText = textFieldState.text.toString() val cursorPos = currentText.length // Assume cursor at end for simplicity val separator = ' ' - + // Find start of current word var start = cursorPos while (start > 0 && currentText[start - 1] != separator) start-- - + // Build new text with replacement val replacement = suggestion.toString() + separator val newText = currentText.substring(0, start) + replacement - + // Replace all text and place cursor at end textFieldState.edit { replace(0, length, newText) placeCursorAtEnd() } + + if (suggestion is Suggestion.EmoteSuggestion) { + addEmoteUsage(suggestion.emote.id) + } + } + + fun addEmoteUsage(emoteId: String) { + viewModelScope.launch { + emoteUsageRepository.addEmoteUsage(emoteId) + } } fun toggleEmoteMenu() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 61a0653fd..70548e517 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -678,9 +678,11 @@ fun MainScreen( .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) { EmoteMenu( - onEmoteClick = { code, _ -> + onEmoteClick = { code, id -> chatInputViewModel.insertText("$code ") + chatInputViewModel.addEmoteUsage(id) }, + onBackspace = chatInputViewModel::deleteLastWord, modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt index d3254cbfe..2607aff25 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.main.compose.sheets 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 @@ -10,7 +11,7 @@ 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.ui.platform.LocalDensity +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.LazyVerticalGrid @@ -18,36 +19,41 @@ 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.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.emotemenu.EmoteItem import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab import com.flxrs.dankchat.main.compose.EmoteMenuViewModel +import com.flxrs.dankchat.preferences.components.DankBackground import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.Surface -import com.flxrs.dankchat.preferences.components.DankBackground - @OptIn(ExperimentalMaterial3Api::class) @Composable fun EmoteMenu( onEmoteClick: (String, String) -> Unit, + onBackspace: () -> Unit, viewModel: EmoteMenuViewModel = koinViewModel(), modifier: Modifier = Modifier ) { @@ -95,80 +101,100 @@ fun EmoteMenu( val navBarBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current) val navBarBottomDp = with(LocalDensity.current) { navBarBottom.toDp() } - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - beyondViewportPageCount = 1 - ) { page -> - val tab = tabItems[page] - val items = tab.items + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1 + ) { page -> + val tab = tabItems[page] + 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) // Offset below logo - ) - } - } else { - val gridState = if (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 = 8.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) + 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) // Offset below logo + ) + } + } else { + val gridState = if (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" + } } - }, - contentType = { item -> + ) { 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.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) } - ) + 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) } + ) + } } } } } } + + // 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, + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0428976a..f128d5f0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,7 @@ Paste Channel name Recent + Backspace Subs Channel Global From 1db5f72edde29bf50bc86b548e0210a62c97d50b Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 027/349] feat(compose): Add message history sheet with search --- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 9 +- .../compose/MessageHistoryComposeViewModel.kt | 68 +++++ .../dankchat/chat/search/ChatItemFilter.kt | 76 +++++ .../dankchat/chat/search/ChatSearchFilter.kt | 14 + .../chat/search/ChatSearchFilterParser.kt | 59 ++++ .../chat/user/compose/UserPopupDialog.kt | 13 + .../main/compose/ChannelPagerViewModel.kt | 22 +- .../dankchat/main/compose/ChatBottomBar.kt | 4 +- .../dankchat/main/compose/ChatInputLayout.kt | 14 + .../dankchat/main/compose/FloatingToolbar.kt | 1 + .../main/compose/FullScreenSheetOverlay.kt | 25 ++ .../flxrs/dankchat/main/compose/MainAppBar.kt | 14 + .../flxrs/dankchat/main/compose/MainScreen.kt | 39 +-- .../main/compose/MainScreenDialogs.kt | 8 +- .../main/compose/SheetNavigationViewModel.kt | 5 + .../compose/sheets/MessageHistorySheet.kt | 278 ++++++++++++++++++ app/src/main/res/values-ar/strings.xml | 1 + .../main/res/values-b+zh+Hant+TW/strings.xml | 1 + app/src/main/res/values-be-rBY/strings.xml | 3 + app/src/main/res/values-ca/strings.xml | 4 +- app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en-rAU/strings.xml | 4 +- app/src/main/res/values-en-rGB/strings.xml | 4 +- app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 3 + app/src/main/res/values-fa-rIR/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 4 +- app/src/main/res/values-fr-rFR/strings.xml | 3 + app/src/main/res/values-hu-rHU/strings.xml | 3 + app/src/main/res/values-in-rID/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja-rJP/strings.xml | 3 + app/src/main/res/values-kk-rKZ/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt-rPT/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values-sr/strings.xml | 4 +- app/src/main/res/values-tr-rTR/strings.xml | 3 + app/src/main/res/values-uk-rUA/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 43 files changed, 682 insertions(+), 41 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilter.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index c70ed1e7d..c88877228 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -122,10 +122,13 @@ fun ChatScreen( val reversedMessages = remember(messages) { messages.asReversed() } - // Handle scroll-to-message requests + // 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) { + LaunchedEffect(scrollToMessageId, hasMessages) { val targetId = scrollToMessageId ?: return@LaunchedEffect + if (!hasMessages) return@LaunchedEffect val index = reversedMessages.indexOfFirst { it.id == targetId } if (index >= 0) { shouldAutoScroll = false @@ -184,7 +187,7 @@ fun ChatScreen( } // FABs at bottom-end with coordinated position animation - val showScrollFab = !isAtBottom && messages.isNotEmpty() + val showScrollFab = !shouldAutoScroll && messages.isNotEmpty() val bottomContentPadding = contentPadding.calculateBottomPadding() val fabBottomPadding by animateDpAsState( targetValue = when { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt new file mode 100644 index 000000000..0cfbd5aef --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt @@ -0,0 +1,68 @@ +package com.flxrs.dankchat.chat.history.compose + +import android.content.Context +import androidx.lifecycle.ViewModel +import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.search.ChatItemFilter +import com.flxrs.dankchat.chat.search.ChatSearchFilter +import com.flxrs.dankchat.chat.search.ChatSearchFilterParser +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +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.flowOn +import kotlinx.coroutines.flow.map +import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.InjectedParam + +@KoinViewModel +class MessageHistoryComposeViewModel( + @InjectedParam private val channel: UserName, + chatRepository: ChatRepository, + private val context: Context, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, + private val preferenceStore: DankChatPreferenceStore, +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val filters: Flow> = _searchQuery + .debounce(300) + .map { ChatSearchFilterParser.parse(it) } + .distinctUntilChanged() + + val historyUiStates: Flow> = combine( + chatRepository.getChat(channel), + filters, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, activeFilters, appearanceSettings, chatSettings -> + messages + .filter { ChatItemFilter.matches(it, activeFilters) } + .map { + it.toChatMessageUiState( + context = context, + appearanceSettings = appearanceSettings, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = false, + ) + } + }.flowOn(Dispatchers.Default) + + fun updateSearchQuery(query: String) { + _searchQuery.value = query + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt new file mode 100644 index 000000000..15d736c59 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt @@ -0,0 +1,76 @@ +package com.flxrs.dankchat.chat.search + +import com.flxrs.dankchat.chat.ChatItem +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 { + val message = item.message + return when (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 { + val message = item.message + return when (message) { + is PrivMessage -> { + message.name.value.equals(name, ignoreCase = true) || + message.displayName.value.equals(name, ignoreCase = true) + } + else -> false + } + } + + private fun matchLink(item: ChatItem): Boolean { + val message = item.message + return when (message) { + is PrivMessage -> URL_REGEX.containsMatchIn(message.message) + else -> false + } + } + + private fun matchEmote(item: ChatItem, emoteName: String?): Boolean { + val message = item.message + return when (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 { + val message = item.message + return when (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/chat/search/ChatSearchFilter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilter.kt new file mode 100644 index 000000000..ed52d8ec7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilter.kt @@ -0,0 +1,14 @@ +package com.flxrs.dankchat.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/chat/search/ChatSearchFilterParser.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt new file mode 100644 index 000000000..37f963127 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt @@ -0,0 +1,59 @@ +package com.flxrs.dankchat.chat.search + +object ChatSearchFilterParser { + + fun parse(query: String): List { + if (query.isBlank()) return emptyList() + + return query.trim().split("\\s+".toRegex()).mapNotNull { token -> + parseToken(token) + } + } + + private fun parseToken(token: String): 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" -> { + if (value.isNotEmpty()) { + return ChatSearchFilter.Author(name = value, negate = negate) + } + } + "has" -> { + return when (value.lowercase()) { + "link" -> ChatSearchFilter.HasLink(negate = negate) + "emote" -> ChatSearchFilter.HasEmote(emoteName = null, negate = negate) + else -> { + if (value.isNotEmpty()) { + ChatSearchFilter.HasEmote(emoteName = value, negate = negate) + } else { + null + } + } + } + } + "badge" -> { + if (value.isNotEmpty()) { + return ChatSearchFilter.BadgeFilter(badgeName = value.lowercase(), negate = negate) + } + } + } + } + + return ChatSearchFilter.Text(query = raw, negate = negate) + } + + private fun extractNegation(token: String): Pair { + return when { + token.startsWith('!') || token.startsWith('-') -> true to token.substring(1) + else -> false to token + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index e5d83ca01..f31a2bad0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -17,6 +17,7 @@ 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.Person +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Report import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -71,6 +72,7 @@ fun UserPopupDialog( onWhisper: (String) -> Unit, onOpenChannel: (String) -> Unit, onReport: (String) -> Unit, + onMessageHistory: ((String) -> Unit)? = null, ) { var showBlockConfirmation by remember { mutableStateOf(false) } @@ -205,6 +207,17 @@ fun UserPopupDialog( }, colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) + if (onMessageHistory != null) { + ListItem( + headlineContent = { Text(stringResource(R.string.message_history)) }, + leadingContent = { Icon(Icons.Default.History, contentDescription = null) }, + modifier = Modifier.clickable { + onMessageHistory(s.userName.value) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + } ListItem( headlineContent = { Text(if (s.isBlocked) stringResource(R.string.user_popup_unblock) else stringResource(R.string.user_popup_block)) }, leadingContent = { Icon(Icons.Default.Block, contentDescription = null) }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt index a5bb108a9..2fb1f06e3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -9,14 +9,10 @@ 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.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @KoinViewModel @@ -36,9 +32,6 @@ class ChannelPagerViewModel( ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelPagerUiState()) - private val _scrollToMessage = MutableSharedFlow() - val scrollToMessage: SharedFlow = _scrollToMessage.asSharedFlow() - fun onPageChanged(page: Int) { val channels = preferenceStore.channels if (page in channels.indices) { @@ -49,18 +42,21 @@ class ChannelPagerViewModel( } } - fun jumpToMessage(channel: UserName, messageId: String): Boolean { + /** + * 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 false - if (chatRepository.getChat(channel).value.none { it.message.id == messageId }) return false + if (index < 0) return null + if (chatRepository.getChat(channel).value.none { it.message.id == messageId }) return null onPageChanged(index) - viewModelScope.launch { _scrollToMessage.emit(ScrollToMessageEvent(index, messageId)) } - return true + return JumpTarget(channelIndex = index, channel = channel, messageId = messageId) } } -data class ScrollToMessageEvent(val channelIndex: Int, val messageId: String) +data class JumpTarget(val channelIndex: Int, val channel: UserName, val messageId: String) @Immutable data class ChannelPagerUiState( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index e6e801d6b..2920d4051 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -41,6 +41,7 @@ fun ChatBottomBar( onToggleInput: () -> Unit, onToggleStream: () -> Unit, onChangeRoomState: () -> Unit, + onSearchClick: () -> Unit, onNewWhisper: (() -> Unit)?, onInputHeightChanged: (Int) -> Unit, ) { @@ -76,6 +77,7 @@ fun ChatBottomBar( onToggleInput = onToggleInput, onToggleStream = onToggleStream, onChangeRoomState = onChangeRoomState, + onSearchClick = onSearchClick, onNewWhisper = onNewWhisper, showQuickActions = !isSheetOpen, modifier = Modifier.onGloballyPositioned { coordinates -> @@ -85,7 +87,7 @@ fun ChatBottomBar( } // Sticky helper text + nav bar spacer when input is hidden - if (!showInput) { + if (!showInput && !isSheetOpen) { val helperText = inputState.helperText if (!helperText.isNullOrEmpty()) { Surface( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 0d4d2cc4e..2ad3c49c6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Videocam @@ -104,6 +105,7 @@ fun ChatInputLayout( whisperTarget: UserName?, onWhisperDismiss: () -> Unit, onChangeRoomState: () -> Unit, + onSearchClick: () -> Unit = {}, onNewWhisper: (() -> Unit)? = null, showQuickActions: Boolean = true, modifier: Modifier = Modifier @@ -347,6 +349,18 @@ fun ChatInputLayout( } } + // Search Button + IconButton( + onClick = onSearchClick, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.message_history), + ) + } + // History Button (Always visible) IconButton( onClick = onLastMessageClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index b3b2f3368..dc5157a75 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -97,6 +97,7 @@ sealed interface ToolbarAction { data object ClearChat : ToolbarAction data object ToggleStream : ToolbarAction data object OpenSettings : ToolbarAction + data object MessageHistory : ToolbarAction } @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index 4b6ba7a4b..d1966d885 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -21,6 +21,7 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.main.compose.sheets.MentionSheet +import com.flxrs.dankchat.main.compose.sheets.MessageHistorySheet import com.flxrs.dankchat.main.compose.sheets.RepliesSheet import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -137,6 +138,30 @@ fun FullScreenSheetOverlay( bottomContentPadding = bottomContentPadding, ) } + is FullScreenSheetState.History -> { + MessageHistorySheet( + channel = sheetState.channel, + initialFilter = sheetState.initialFilter, + appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, + onUserClick = userClickHandler, + onMessageLongClick = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = false, + canReply = false, + canCopy = true, + canJump = true, + ) + ) + }, + onEmoteClick = onEmoteClick, + onJumpToMessage = onJumpToMessage, + ) + } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index 174bccc99..8af15cfe6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -66,6 +66,7 @@ fun MainAppBar( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, + onMessageHistory: () -> Unit, onCaptureImage: () -> Unit, onCaptureVideo: () -> Unit, onChooseMedia: () -> Unit, @@ -200,6 +201,13 @@ fun MainAppBar( currentMenu = null } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.message_history)) }, + onClick = { + onMessageHistory() + currentMenu = null + } + ) DropdownMenuItem( text = { Text(stringResource(R.string.remove_channel)) }, onClick = { @@ -298,6 +306,7 @@ fun ToolbarOverflowMenu( onRemoveChannel: () -> Unit, onReportChannel: () -> Unit, onBlockChannel: () -> Unit, + onMessageHistory: () -> Unit, onCaptureImage: () -> Unit, onCaptureVideo: () -> Unit, onChooseMedia: () -> Unit, @@ -382,6 +391,10 @@ fun ToolbarOverflowMenu( text = { Text(stringResource(R.string.open_channel)) }, onClick = { onOpenChannel(); onDismiss() } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.message_history)) }, + onClick = { onMessageHistory(); onDismiss() } + ) DropdownMenuItem( text = { Text(stringResource(R.string.remove_channel)) }, onClick = { onRemoveChannel(); onDismiss() } @@ -502,6 +515,7 @@ fun InlineOverflowMenu( InlineMenuItem(text = stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) { onAction(ToolbarAction.ToggleStream); onDismiss() } } InlineMenuItem(text = stringResource(R.string.open_channel)) { onAction(ToolbarAction.OpenChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.message_history)) { onAction(ToolbarAction.MessageHistory); onDismiss() } InlineMenuItem(text = stringResource(R.string.remove_channel)) { onAction(ToolbarAction.RemoveChannel); onDismiss() } InlineMenuItem(text = stringResource(R.string.report_channel)) { onAction(ToolbarAction.ReportChannel); onDismiss() } if (isLoggedIn) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 70548e517..1dc11daae 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator @@ -166,6 +167,9 @@ fun MainScreen( val mainEventBus: MainEventBus = koinInject() 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 @@ -345,10 +349,12 @@ fun MainScreen( onReportChannel = onReportChannel, onOpenUrl = onOpenUrl, onJumpToMessage = { messageId, channel -> - val jumped = channelPagerViewModel.jumpToMessage(channel, messageId) - if (jumped) { + 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(context.getString(R.string.message_not_in_history)) @@ -428,7 +434,8 @@ fun MainScreen( val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() val gestureInputHidden by mainScreenViewModel.gestureInputHidden.collectAsStateWithLifecycle() val gestureToolbarHidden by mainScreenViewModel.gestureToolbarHidden.collectAsStateWithLifecycle() - val effectiveShowInput = showInputState && !gestureInputHidden + val isHistorySheetOpen = fullScreenSheetState is FullScreenSheetState.History + val effectiveShowInput = showInputState && !gestureInputHidden && !isHistorySheetOpen val effectiveShowAppBar = showAppBar && !gestureToolbarHidden val toolbarTracker = remember { @@ -476,7 +483,7 @@ fun MainScreen( val composePagerState = rememberPagerState( initialPage = pagerState.currentPage, pageCount = { pagerState.channels.size } - ) + ).also { composePagerStateRef = it } var inputHeightPx by remember { mutableIntStateOf(0) } if (!effectiveShowInput) inputHeightPx = 0 val inputHeightDp = with(density) { inputHeightPx.toDp() } @@ -516,17 +523,6 @@ fun MainScreen( } } - // Jump-to-message: per-channel scroll targets - val scrollTargets = remember { mutableStateMapOf() } - - LaunchedEffect(Unit) { - channelPagerViewModel.scrollToMessage.collect { event -> - val channel = pagerState.channels.getOrNull(event.channelIndex) ?: return@collect - composePagerState.scrollToPage(event.channelIndex) - scrollTargets[channel] = event.messageId - } - } - // Pager swipe reveals toolbar LaunchedEffect(composePagerState.isScrollInProgress) { if (composePagerState.isScrollInProgress) { @@ -587,6 +583,7 @@ fun MainScreen( onToggleInput = mainScreenViewModel::toggleInput, onToggleStream = { activeChannel?.let { streamViewModel.toggleStream(it) } }, onChangeRoomState = dialogViewModel::showRoomState, + onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, onInputHeightChanged = { inputHeightPx = it }, ) @@ -633,6 +630,7 @@ fun MainScreen( ToolbarAction.ClearChat -> dialogViewModel.showClearChat() ToolbarAction.ToggleStream -> activeChannel?.let { streamViewModel.toggleStream(it) } ToolbarAction.OpenSettings -> onNavigateToSettings() + ToolbarAction.MessageHistory -> activeChannel?.let { sheetNavigationViewModel.openHistory(it) } } } @@ -816,6 +814,10 @@ fun MainScreen( // Shared fullscreen sheet overlay val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> + val effectiveBottomPadding = when { + !effectiveShowInput -> bottomPadding + max(navBarHeightDp, roundedCornerBottomPadding) + else -> bottomPadding + } FullScreenSheetOverlay( sheetState = fullScreenSheetState, isLoggedIn = isLoggedIn, @@ -831,8 +833,9 @@ fun MainScreen( onEmoteClick = dialogViewModel::showEmoteInfo, onWhisperReply = chatInputViewModel::setWhisperTarget, onJumpToMessage = { messageId, channel -> - val jumped = channelPagerViewModel.jumpToMessage(channel, messageId) - if (jumped) { + val target = channelPagerViewModel.resolveJumpTarget(channel, messageId) + if (target != null) { + scrollTargets[target.channel] = target.messageId sheetNavigationViewModel.closeFullScreenSheet() } else { scope.launch { @@ -840,7 +843,7 @@ fun MainScreen( } } }, - bottomContentPadding = bottomPadding, + bottomContentPadding = effectiveBottomPadding, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index bcd98fe33..fd50ffc2a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -273,7 +273,13 @@ fun MainScreenDialogs( onOpenChannel = { _ -> onOpenChannel() }, onReport = { _ -> onReportChannel() - } + }, + onMessageHistory = { userName -> + params.channel?.let { channel -> + sheetNavigationViewModel.openHistory(channel, "from:$userName") + } + dialogViewModel.dismissUserPopup() + }, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt index cb16a59ba..20028d05b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt @@ -28,6 +28,10 @@ class SheetNavigationViewModel : ViewModel() { _fullScreenSheetState.value = FullScreenSheetState.Whisper } + fun openHistory(channel: UserName, initialFilter: String = "") { + _fullScreenSheetState.value = FullScreenSheetState.History(channel, initialFilter) + } + fun closeFullScreenSheet() { _fullScreenSheetState.value = FullScreenSheetState.Closed } @@ -64,6 +68,7 @@ sealed interface FullScreenSheetState { data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState data object Mention : FullScreenSheetState data object Whisper : FullScreenSheetState + data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState } sealed interface InputSheetState { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt new file mode 100644 index 000000000..aa7a12d1e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -0,0 +1,278 @@ +package com.flxrs.dankchat.main.compose.sheets + +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.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.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.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +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.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.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.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.LocalPlatformContext +import coil3.imageLoader +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ChatScreen +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.chat.history.compose.MessageHistoryComposeViewModel +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import kotlinx.coroutines.CancellationException +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun MessageHistorySheet( + channel: UserName, + initialFilter: String, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + 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, + onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, +) { + val viewModel: MessageHistoryComposeViewModel = koinViewModel( + key = channel.value, + parameters = { parametersOf(channel) }, + ) + + LaunchedEffect(initialFilter) { + if (initialFilter.isNotEmpty()) { + viewModel.updateSearchQuery(initialFilter) + } + } + + val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle( + initialValue = appearanceSettingsDataStore.current() + ) + val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + + 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 (e: CancellationException) { + backProgress = 0f + } + } + + val context = LocalPlatformContext.current + val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + } + ) { + CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { + ChatScreen( + messages = messages, + fontSize = appearanceSettings.fontSize.toFloat(), + modifier = Modifier.fillMaxSize(), + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onJumpToMessage = onJumpToMessage, + contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), + ) + } + + // Floating toolbar with gradient scrim - back pill + channel name pill + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + 0.75f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + 1f to MaterialTheme.colorScheme.surface.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, + ) { + // Back navigation pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + + // Channel name pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(R.string.message_history_title, channel.value), + style = MaterialTheme.typography.titleSmall, + ) + } + } + } + } + + // Bottom search bar with upward gradient scrim + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = currentImeDp) + .background( + brush = Brush.verticalGradient( + 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0f), + 0.25f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + ) + ) + .navigationBarsPadding() + .onGloballyPositioned { coordinates -> + searchBarHeightPx = coordinates.size.height + } + .padding(top = 16.dp, bottom = 8.dp) + .padding(horizontal = 8.dp), + ) { + SearchToolbar( + searchQuery = searchQuery, + onSearchQueryChange = viewModel::updateSearchQuery, + ) + } + } +} + +@Composable +private fun SearchToolbar( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, +) { + var textState by remember(searchQuery) { mutableStateOf(searchQuery) } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(searchQuery) { + if (textState != searchQuery) { + textState = searchQuery + } + } + + val textFieldColors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ) + + TextField( + value = textState, + onValueChange = { + textState = it + onSearchQueryChange(it) + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.search_messages_hint)) }, + trailingIcon = { + if (textState.isNotEmpty()) { + IconButton(onClick = { + textState = "" + onSearchQueryChange("") + }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.dialog_dismiss), + ) + } + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), + shape = MaterialTheme.shapes.extraLarge, + colors = textFieldColors, + ) +} 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..313164c44 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 @@ -398,4 +398,5 @@ 顯示實況類別 同時也顯示實況類別 開關輸入框 + 歷史紀錄:%1$s diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 94875e331..b07ba9638 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -354,6 +354,9 @@ Больш… Перайсці да паведамлення Паведамленне больш не ў гісторыі чата + Гісторыя паведамленняў + Гісторыя: %1$s + Пошук паведамленняў… У адказ @%1$s Галіна адказаў не знойдзена Паведамленне не знойдзена diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 096bd935b..ccbe16b86 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -303,4 +303,6 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Netejar el xat? Anar al missatge El missatge ja no és a l\'historial del xat - + Historial de missatges + Historial: %1$s + Cerca missatges… diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d80ecea90..deb5a5475 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -361,6 +361,9 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Více… Přejít na zprávu Zpráva již není v historii chatu + Historie zpráv + Historie: %1$s + Hledat zprávy… Odpověď uživateli @%1$s Vlákno odpovědí nebylo nalezeno Zpráva nenalezena diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 58b55e6c1..f7fb236b6 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -364,6 +364,9 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Mehr… Zur Nachricht springen Nachricht nicht mehr im Chatverlauf + Nachrichtenhistorie + Verlauf: %1$s + Nachrichten suchen… Antwort an @%1$s Antwortverlauf nicht gefunden Nachricht nicht gefunden diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 673b9f5c7..5bd294e98 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -240,4 +240,6 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Clear chat? Jump to message Message no longer in chat history - + Message history + History: %1$s + Search messages… diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index baba179e5..aa32bfcf4 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -241,4 +241,6 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Clear chat? Jump to message Message no longer in chat history - + Message history + History: %1$s + Search messages… diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 93df23163..bc184254e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -357,6 +357,9 @@ More… Jump to message Message no longer in chat history + Message history + History: %1$s + Search messages… Replying to @%1$s Reply thread not found Message not found diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 20bbbf130..1872e36b2 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -363,6 +363,9 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Ver más… Ir al mensaje El mensaje ya no está en el historial del chat + Historial de mensajes + Historial: %1$s + Buscar mensajes… Respondiendo a @%1$s Respuesta a hilo no encontrada Mensaje no encontrado 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 e97dd5678..45396506b 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -267,4 +267,6 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Tyhjennetäänkö chat? Siirry viestiin Viesti ei ole enää chat-historiassa - + Viestihistoria + Historia: %1$s + Hae viestejä… diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b54ccb172..38d7800df 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -359,6 +359,9 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Plus… Aller au message Le message n\'est plus dans l\'historique du chat + Historique des messages + Historique : %1$s + Rechercher des messages… Répondre à @%1$s Sujet de réponse introuvable Message non trouvé diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index e77c8adc4..90c3bbe05 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -354,6 +354,9 @@ Több… Ugrás az üzenethez Az üzenet már nincs a csevegési előzményekben + Üzenetelőzmények + Előzmények: %1$s + Üzenetek keresése… Válaszol neki @%1$s Gondolatmenet nem található Üzenet nem található 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 c51d18c0d..400857f37 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -353,6 +353,9 @@ Altro… Vai al messaggio Il messaggio non è più nella cronologia della chat + Cronologia messaggi + Cronologia: %1$s + Cerca messaggi… Stai rispondendo a @%1$s Thread risposta non trovato Messaggio non trovato diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index c5dafb8c6..ac77054a3 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -353,6 +353,9 @@ もっとみる… メッセージに移動 メッセージはチャット履歴にありません + メッセージ履歴 + 履歴:%1$s + メッセージを検索… \@%1$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..bcdd31282 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -319,4 +319,5 @@ Жанды вит %1$d қарау құралы үшін %2$s Жанды вит %1$d қарау құралы үшін %2$s + Тарих: %1$s diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index ca6013e42..1825cdc1b 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -383,4 +383,5 @@ %d ମାସ %d ମାସ + ଇତିହାସ: %1$s diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index f610e53b3..77542bc2c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -361,6 +361,9 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Więcej… Przejdź do wiadomości Wiadomość nie jest już w historii czatu + Historia wiadomości + Historia: %1$s + Szukaj wiadomości… Odpowiadasz @%1$s Nie znaleziono wątku Nie znaleziono wiadomości diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index aacc6f9cc..4904cdf77 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -354,6 +354,9 @@ Mais… Ir para a mensagem Mensagem não está mais no histórico do chat + Histórico de mensagens + Histórico: %1$s + Pesquisar mensagens… Respondendo a @%1$s Tópico não encontrado Mensagem não encontrada diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 93535b192..9981784d5 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -354,6 +354,9 @@ Mais… Ir para a mensagem Mensagem já não está no histórico do chat + Histórico de mensagens + Histórico: %1$s + Pesquisar mensagens… Responder a @%1$s Thread de resposta não encontrado Mensagem não encontrada diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 77277221a..595a58ee8 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -359,6 +359,9 @@ Ещё… Перейти к сообщению Сообщение больше не в истории чата + История сообщений + История: %1$s + Поиск сообщений… В ответ @%1$s Ветка ответов не найдена Сообщение не найдено diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 80c3dac48..64b8dc711 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -208,4 +208,6 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Obrisati čet? Иди на поруку Порука више није у историји ћаскања - + Историја порука + Историја: %1$s + Претражи поруке… diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 8bee83c8b..ff8061238 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -363,6 +363,9 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Daha fazlası… Mesaja git Mesaj artık sohbet geçmişinde değil + Mesaj geçmişi + Geçmiş: %1$s + Mesaj ara… \@%1$s yanıtlanıyor Yanıt akışı bulunamadı Mesaj bulunamadı diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 5a17eebf8..630cb990f 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -361,6 +361,9 @@ Більше… Перейти до повідомлення Повідомлення більше немає в історії чату + Історія повідомлень + Історія: %1$s + Пошук повідомлень… Відповісти @%1$s Тема відповіді не знайдена Повідомлення не знайдено diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f128d5f0c..49c57825d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,6 +439,9 @@ More… Jump to message Message no longer in chat history + Message history + History: %1$s + Search messages… Replying to @%1$s Whispering @%1$s Send a whisper From 55ae48bee78b7ce9fbabe8cb08c7dd756b086be6 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 028/349] feat(compose): Add configurable input bar actions --- .../dankchat/main/compose/ChatBottomBar.kt | 5 + .../dankchat/main/compose/ChatInputLayout.kt | 404 ++++++++++++++---- .../flxrs/dankchat/main/compose/MainScreen.kt | 10 + .../appearance/AppearanceSettings.kt | 9 + .../appearance/AppearanceSettingsDataStore.kt | 3 + app/src/main/res/values/strings.xml | 10 + 6 files changed, 352 insertions(+), 89 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index 2920d4051..b59d6eb6c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.preferences.appearance.InputAction @OptIn(ExperimentalFoundationApi::class) @Composable @@ -32,6 +33,7 @@ fun ChatBottomBar( isStreamActive: Boolean, hasStreamData: Boolean, isSheetOpen: Boolean, + inputActions: Set, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -43,6 +45,7 @@ fun ChatBottomBar( onChangeRoomState: () -> Unit, onSearchClick: () -> Unit, onNewWhisper: (() -> Unit)?, + onInputActionsChanged: (Set) -> Unit, onInputHeightChanged: (Int) -> Unit, ) { Column(modifier = Modifier.fillMaxWidth()) { @@ -66,6 +69,7 @@ fun ChatBottomBar( isModerator = isModerator, isStreamActive = isStreamActive, hasStreamData = hasStreamData, + inputActions = inputActions, onSend = onSend, onLastMessageClick = onLastMessageClick, onEmoteClick = onEmoteClick, @@ -77,6 +81,7 @@ fun ChatBottomBar( onToggleInput = onToggleInput, onToggleStream = onToggleStream, onChangeRoomState = onChangeRoomState, + onInputActionsChanged = onInputActionsChanged, onSearchClick = onSearchClick, onNewWhisper = onNewWhisper, showQuickActions = !isSheetOpen, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 2ad3c49c6..d8651a174 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -36,20 +37,26 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Keyboard import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings 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.VisibilityOff +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenuItem +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.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -61,6 +68,7 @@ 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.vector.ImageVector import androidx.compose.ui.layout.layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -77,6 +85,9 @@ import androidx.compose.ui.window.PopupProperties import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState +import com.flxrs.dankchat.preferences.appearance.InputAction + +private const val MAX_INPUT_ACTIONS = 4 @Composable fun ChatInputLayout( @@ -94,6 +105,7 @@ fun ChatInputLayout( isModerator: Boolean, isStreamActive: Boolean, hasStreamData: Boolean, + inputActions: Set, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -105,6 +117,7 @@ fun ChatInputLayout( whisperTarget: UserName?, onWhisperDismiss: () -> Unit, onChangeRoomState: () -> Unit, + onInputActionsChanged: (Set) -> Unit, onSearchClick: () -> Unit = {}, onNewWhisper: (() -> Unit)? = null, showQuickActions: Boolean = true, @@ -137,6 +150,7 @@ fun ChatInputLayout( } var quickActionsExpanded by remember { mutableStateOf(false) } + var showConfigSheet by remember { mutableStateOf(false) } val topEndRadius by animateDpAsState( targetValue = if (quickActionsExpanded) 0.dp else 24.dp, label = "topEndCornerRadius" @@ -296,33 +310,113 @@ fun ChatInputLayout( .fillMaxWidth() .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) ) { - // Emote/Keyboard Button (Left) - IconButton( - onClick = { - if (isEmoteMenuOpen) { - focusRequester.requestFocus() + // Configurable action icons (enum order) + for (action in InputAction.entries) { + if (action !in inputActions) continue + when (action) { + InputAction.Emotes -> { + IconButton( + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() + } + onEmoteClick() + }, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, + contentDescription = stringResource( + if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint + ), + ) + } + } + + InputAction.Search -> { + IconButton( + onClick = onSearchClick, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.message_history), + ) + } + } + + InputAction.History -> { + IconButton( + onClick = onLastMessageClick, + enabled = enabled, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.resume_scroll), + ) + } + } + + InputAction.Stream -> { + if (hasStreamData || isStreamActive) { + IconButton( + onClick = onToggleStream, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + contentDescription = stringResource(R.string.toggle_stream), + ) + } + } + } + + InputAction.RoomState -> { + if (isModerator) { + IconButton( + onClick = onChangeRoomState, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = stringResource(R.string.menu_room_state), + ) + } + } + } + + InputAction.Fullscreen -> { + IconButton( + onClick = onToggleFullscreen, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + contentDescription = stringResource(R.string.toggle_fullscreen), + ) + } + } + + InputAction.HideInput -> { + IconButton( + onClick = onToggleInput, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = stringResource(R.string.menu_hide_input), + ) + } } - onEmoteClick() - }, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - if (isEmoteMenuOpen) { - Icon( - imageVector = Icons.Default.Keyboard, - contentDescription = stringResource(R.string.dialog_dismiss), - ) - } else { - Icon( - imageVector = Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.emote_menu_hint), - ) } } Spacer(modifier = Modifier.weight(1f)) - // Quick Actions Button + // Quick Actions / Overflow Button if (showQuickActions) { IconButton( onClick = { quickActionsExpanded = !quickActionsExpanded }, @@ -349,30 +443,6 @@ fun ChatInputLayout( } } - // Search Button - IconButton( - onClick = onSearchClick, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.message_history), - ) - } - - // History Button (Always visible) - IconButton( - onClick = onLastMessageClick, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.History, - contentDescription = stringResource(R.string.resume_scroll), - ) - } - // Send Button (Right) SendButton( enabled = canSend, @@ -423,70 +493,226 @@ fun ChatInputLayout( color = surfaceColor, ) { Column(modifier = Modifier.width(IntrinsicSize.Max)) { - if (hasStreamData || isStreamActive) { - DropdownMenuItem( - text = { Text(stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) }, - onClick = { - onToggleStream() - quickActionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, - contentDescription = null - ) - } + // Overflow items: actions NOT in inputActions (with conditions) + for (action in InputAction.entries) { + if (action in inputActions) continue + val overflowItem = getOverflowItem( + action = action, + isStreamActive = isStreamActive, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = isModerator, ) - } - DropdownMenuItem( - text = { Text(stringResource(if (isFullscreen) R.string.menu_exit_fullscreen else R.string.menu_fullscreen)) }, - onClick = { - onToggleFullscreen() - quickActionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, - contentDescription = null + if (overflowItem != null) { + DropdownMenuItem( + text = { Text(stringResource(overflowItem.labelRes)) }, + onClick = { + when (action) { + InputAction.Emotes -> onEmoteClick() + InputAction.Search -> onSearchClick() + InputAction.History -> onLastMessageClick() + InputAction.Stream -> onToggleStream() + InputAction.RoomState -> onChangeRoomState() + InputAction.Fullscreen -> onToggleFullscreen() + InputAction.HideInput -> onToggleInput() + } + quickActionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = overflowItem.icon, + contentDescription = null + ) + } ) } - ) + } + + HorizontalDivider() + + // Configure actions item DropdownMenuItem( - text = { Text(stringResource(R.string.menu_hide_input)) }, + text = { Text(stringResource(R.string.input_action_configure)) }, onClick = { - onToggleInput() quickActionsExpanded = false + showConfigSheet = true }, leadingIcon = { Icon( - imageVector = Icons.Default.VisibilityOff, + imageVector = Icons.Default.Settings, contentDescription = null ) } ) - if (isModerator) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_room_state)) }, - onClick = { - onChangeRoomState() - quickActionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Shield, - contentDescription = null - ) - } - ) - } } } } } } } + + if (showConfigSheet) { + InputActionConfigSheet( + inputActions = inputActions, + onInputActionsChanged = onInputActionsChanged, + onDismiss = { showConfigSheet = false }, + ) + } +} + +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.Emotes -> OverflowItem( + labelRes = R.string.input_action_emotes, + icon = Icons.Default.EmojiEmotions, + ) + + InputAction.Search -> OverflowItem( + labelRes = R.string.input_action_search, + icon = Icons.Default.Search, + ) + + InputAction.History -> OverflowItem( + labelRes = R.string.input_action_history, + 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.Default.VideocamOff else Icons.Default.Videocam, + ) + + else -> null + } + + InputAction.RoomState -> when { + isModerator -> OverflowItem( + labelRes = R.string.menu_room_state, + icon = Icons.Default.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, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InputActionConfigSheet( + inputActions: Set, + onInputActionsChanged: (Set) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val atLimit = inputActions.size >= MAX_INPUT_ACTIONS + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Text( + text = stringResource(R.string.input_actions_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + if (atLimit) { + Text( + text = stringResource(R.string.input_actions_max, MAX_INPUT_ACTIONS), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + for (action in InputAction.entries) { + val checked = action in inputActions + val actionEnabled = checked || !atLimit + + ListItem( + headlineContent = { Text(stringResource(action.labelRes)) }, + leadingContent = { + Icon( + imageVector = action.icon, + contentDescription = null, + ) + }, + trailingContent = { + Checkbox( + checked = checked, + onCheckedChange = null, // handled by row click + enabled = actionEnabled, + ) + }, + modifier = Modifier + .fillMaxWidth() + .then( + if (actionEnabled) { + Modifier.clickable { + val newSet = if (checked) { + inputActions - action + } else { + inputActions + action + } + onInputActionsChanged(newSet) + } + } else { + Modifier + } + ), + ) + } + } + } } +private val InputAction.labelRes: Int + get() = when (this) { + InputAction.Emotes -> R.string.input_action_emotes + InputAction.Search -> R.string.input_action_search + InputAction.History -> R.string.input_action_history + InputAction.Stream -> R.string.input_action_stream + InputAction.RoomState -> R.string.input_action_room_state + InputAction.Fullscreen -> R.string.input_action_fullscreen + InputAction.HideInput -> R.string.input_action_hide_input + } + +private val InputAction.icon: ImageVector + get() = when (this) { + InputAction.Emotes -> Icons.Default.EmojiEmotions + InputAction.Search -> Icons.Default.Search + InputAction.History -> Icons.Default.History + InputAction.Stream -> Icons.Default.Videocam + InputAction.RoomState -> Icons.Default.Shield + InputAction.Fullscreen -> Icons.Default.Fullscreen + InputAction.HideInput -> Icons.Default.VisibilityOff + } + @Composable private fun SendButton( enabled: Boolean, @@ -498,7 +724,7 @@ private fun SendButton( } else { MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) } - + IconButton( onClick = onSend, enabled = enabled, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 1dc11daae..38f843779 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -114,6 +114,7 @@ import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.main.compose.sheets.EmoteMenu 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 com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.preferences.components.DankBackground @@ -432,6 +433,9 @@ fun MainScreen( val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() + val inputActions by appearanceSettingsDataStore.inputActions.collectAsStateWithLifecycle( + initialValue = appearanceSettingsDataStore.current().inputActions + ) val gestureInputHidden by mainScreenViewModel.gestureInputHidden.collectAsStateWithLifecycle() val gestureToolbarHidden by mainScreenViewModel.gestureToolbarHidden.collectAsStateWithLifecycle() val isHistorySheetOpen = fullScreenSheetState is FullScreenSheetState.History @@ -567,6 +571,7 @@ fun MainScreen( isStreamActive = currentStream != null, hasStreamData = hasStreamData, isSheetOpen = isSheetOpen, + inputActions = inputActions, onSend = chatInputViewModel::sendMessage, onLastMessageClick = chatInputViewModel::getLastMessage, onEmoteClick = { @@ -585,6 +590,11 @@ fun MainScreen( onChangeRoomState = dialogViewModel::showRoomState, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, + onInputActionsChanged = { newActions -> + scope.launch { + appearanceSettingsDataStore.update { it.copy(inputActions = newActions) } + } + }, onInputHeightChanged = { inputHeightPx = it }, ) } 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..1460c7b16 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 @@ -2,6 +2,11 @@ package com.flxrs.dankchat.preferences.appearance import kotlinx.serialization.Serializable +@Serializable +enum class InputAction { + Emotes, Search, History, Stream, RoomState, Fullscreen, HideInput +} + @Serializable data class AppearanceSettings( val theme: ThemePreference = ThemePreference.System, @@ -14,6 +19,10 @@ data class AppearanceSettings( val autoDisableInput: Boolean = true, val showChips: Boolean = true, val showChangelogs: Boolean = true, + val inputActions: Set = setOf( + InputAction.Emotes, InputAction.Search, InputAction.History, + InputAction.Stream, InputAction.RoomState, + ), ) enum class ThemePreference { System, Dark, Light } 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..f0c9d76ad 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 @@ -86,6 +86,9 @@ class AppearanceSettingsDataStore( val showInput = settings .map { it.showInput } .distinctUntilChanged() + val inputActions = settings + .map { it.inputActions } + .distinctUntilChanged() fun current() = currentSettings.value diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49c57825d..e40de88e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,16 @@ Exit fullscreen Hide input Room state + Input actions + Maximum of %1$d actions + Emote menu + Search messages + Last message + Toggle stream + Room state + Fullscreen + Hide input + Configure actions… Emote only Subscriber only Slow mode From 38544a52fea971fb681f847884d88ab7642c7d04 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 11:15:56 +0100 Subject: [PATCH 029/349] feat(compose): Add search filter suggestions with value autocomplete --- .../dankchat/chat/compose/ChatComposable.kt | 2 + .../flxrs/dankchat/chat/compose/ChatScreen.kt | 89 ++-- .../compose/MessageHistoryComposeViewModel.kt | 82 +++- .../chat/mention/compose/MentionComposable.kt | 5 +- .../chat/replies/compose/RepliesComposable.kt | 5 +- .../chat/search/ChatSearchFilterParser.kt | 42 +- .../chat/search/SearchFilterSuggestions.kt | 62 +++ .../dankchat/chat/suggestion/Suggestion.kt | 5 + .../chat/suggestion/SuggestionArrayAdapter.kt | 1 + .../chat/suggestion/SuggestionProvider.kt | 21 +- .../dankchat/main/compose/ChatBottomBar.kt | 20 +- .../dankchat/main/compose/ChatInputLayout.kt | 429 ++++++++++-------- .../main/compose/ChatInputViewModel.kt | 62 ++- .../main/compose/FullScreenSheetOverlay.kt | 59 ++- .../flxrs/dankchat/main/compose/MainScreen.kt | 8 +- .../main/compose/MainScreenEventHandler.kt | 5 + .../main/compose/SuggestionDropdown.kt | 24 + .../main/compose/sheets/MentionSheet.kt | 15 +- .../compose/sheets/MessageHistorySheet.kt | 99 ++-- .../main/compose/sheets/RepliesSheet.kt | 15 +- .../appearance/AppearanceSettings.kt | 6 +- .../utils/compose/RoundedCornerPadding.kt | 34 ++ app/src/main/res/values-be-rBY/strings.xml | 6 + app/src/main/res/values-ca/strings.xml | 8 +- app/src/main/res/values-cs/strings.xml | 6 + app/src/main/res/values-de-rDE/strings.xml | 6 + app/src/main/res/values-en-rAU/strings.xml | 8 +- app/src/main/res/values-en-rGB/strings.xml | 8 +- app/src/main/res/values-en/strings.xml | 6 + app/src/main/res/values-es-rES/strings.xml | 6 + app/src/main/res/values-fi-rFI/strings.xml | 8 +- app/src/main/res/values-fr-rFR/strings.xml | 6 + app/src/main/res/values-hu-rHU/strings.xml | 6 + app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values-ja-rJP/strings.xml | 6 + app/src/main/res/values-pl-rPL/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 6 + app/src/main/res/values-pt-rPT/strings.xml | 6 + app/src/main/res/values-ru-rRU/strings.xml | 6 + app/src/main/res/values-sr/strings.xml | 8 +- app/src/main/res/values-tr-rTR/strings.xml | 6 + app/src/main/res/values-uk-rUA/strings.xml | 6 + app/src/main/res/values/strings.xml | 15 +- .../SuggestionProviderExtractWordTest.kt | 101 +++++ .../main/compose/SuggestionReplacementTest.kt | 94 ++++ 45 files changed, 1044 insertions(+), 386 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProviderExtractWordTest.kt create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/main/compose/SuggestionReplacementTest.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index cb83f826f..1b2b4bb5c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -38,6 +38,7 @@ fun ChatComposable( showInput: Boolean = true, isFullscreen: Boolean = false, hasHelperText: Boolean = false, + showFabs: Boolean = true, onRecover: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(), scrollModifier: Modifier = Modifier, @@ -77,6 +78,7 @@ fun ChatComposable( showInput = showInput, isFullscreen = isFullscreen, hasHelperText = hasHelperText, + showFabs = showFabs, onRecover = onRecover, contentPadding = contentPadding, scrollModifier = scrollModifier, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index c88877228..0401d06e9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -39,6 +39,7 @@ 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.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -88,6 +89,8 @@ fun ChatScreen( scrollToMessageId: String? = null, onScrollToMessageHandled: () -> Unit = {}, onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, + containerColor: Color = MaterialTheme.colorScheme.background, + showFabs: Boolean = true, ) { val listState = rememberLazyListState() @@ -141,7 +144,7 @@ fun ChatScreen( Surface( modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = containerColor ) { Box(modifier = Modifier.fillMaxSize()) { LazyColumn( @@ -187,50 +190,52 @@ fun ChatScreen( } // FABs at bottom-end with coordinated position animation - val showScrollFab = !shouldAutoScroll && messages.isNotEmpty() - val bottomContentPadding = contentPadding.calculateBottomPadding() - val fabBottomPadding by animateDpAsState( - targetValue = when { - showInput -> bottomContentPadding - hasHelperText -> maxOf(bottomContentPadding, 48.dp) - else -> maxOf(bottomContentPadding, 24.dp) - }, - animationSpec = if (showInput) snap() else spring(), - label = "fabBottomPadding" - ) - val recoveryBottomPadding by animateDpAsState( - targetValue = if (showScrollFab) 56.dp + 12.dp else 0.dp, - label = "recoveryBottomPadding" - ) - - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + fabBottomPadding), - contentAlignment = Alignment.BottomEnd - ) { - RecoveryFab( - isFullscreen = isFullscreen, - showInput = showInput, - onRecover = onRecover, - modifier = Modifier.padding(bottom = recoveryBottomPadding) + if (showFabs) { + val showScrollFab = !shouldAutoScroll && messages.isNotEmpty() + val bottomContentPadding = contentPadding.calculateBottomPadding() + val fabBottomPadding by animateDpAsState( + targetValue = when { + showInput -> bottomContentPadding + hasHelperText -> maxOf(bottomContentPadding, 48.dp) + else -> maxOf(bottomContentPadding, 24.dp) + }, + animationSpec = if (showInput) snap() else spring(), + label = "fabBottomPadding" + ) + val recoveryBottomPadding by animateDpAsState( + targetValue = if (showScrollFab) 56.dp + 12.dp else 0.dp, + label = "recoveryBottomPadding" ) - AnimatedVisibility( - visible = showScrollFab, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + fabBottomPadding), + contentAlignment = Alignment.BottomEnd ) { - FloatingActionButton( - onClick = { - shouldAutoScroll = true - onScrollDirectionChanged(false) - onScrollToBottom() - }, + RecoveryFab( + isFullscreen = isFullscreen, + showInput = showInput, + onRecover = onRecover, + modifier = Modifier.padding(bottom = recoveryBottomPadding) + ) + AnimatedVisibility( + visible = showScrollFab, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), ) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to bottom" - ) + FloatingActionButton( + onClick = { + shouldAutoScroll = true + onScrollDirectionChanged(false) + onScrollToBottom() + }, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom" + ) + } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt index 0cfbd5aef..acc0d51a9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt @@ -1,27 +1,40 @@ package com.flxrs.dankchat.chat.history.compose import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +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.chat.compose.ChatMessageMapper.toChatMessageUiState import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.search.ChatItemFilter import com.flxrs.dankchat.chat.search.ChatSearchFilter import com.flxrs.dankchat.chat.search.ChatSearchFilterParser +import com.flxrs.dankchat.chat.search.SearchFilterSuggestions +import com.flxrs.dankchat.chat.suggestion.Suggestion +import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.chat.UsersRepository +import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.Dispatchers 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.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop 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.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @@ -29,17 +42,22 @@ import org.koin.core.annotation.InjectedParam class MessageHistoryComposeViewModel( @InjectedParam private val channel: UserName, chatRepository: ChatRepository, + usersRepository: UsersRepository, private val context: Context, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() + val searchFieldState = TextFieldState() - private val filters: Flow> = _searchQuery - .debounce(300) + 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() @@ -62,7 +80,55 @@ class MessageHistoryComposeViewModel( } }.flowOn(Dispatchers.Default) - fun updateSearchQuery(query: String) { - _searchQuery.value = query + private val users: StateFlow> = usersRepository.getUsersFlow(channel) + + private val badgeNames: StateFlow> = chatRepository.getChat(channel) + .map { items -> + items.asSequence() + .map { it.message } + .filterIsInstance() + .flatMap { it.badges } + .mapNotNull { it.badgeTag?.substringBefore('/') } + .toSet() + } + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + + val filterSuggestions: StateFlow> = combine( + searchQuery, + users, + badgeNames, + ) { query, userSet, badges -> + SearchFilterSuggestions.filter(query, users = userSet, badgeNames = badges) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun setInitialQuery(query: String) { + if (query.isNotEmpty()) { + val normalizedQuery = if (query.endsWith(' ')) query else "$query " + searchFieldState.edit { + replace(0, length, normalizedQuery) + 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/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index 87229944b..f8d8abf93 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -14,6 +14,7 @@ import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import androidx.compose.ui.graphics.Color import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore /** @@ -35,6 +36,7 @@ fun MentionComposable( onEmoteClick: (List) -> Unit, onWhisperReply: ((userName: UserName) -> Unit)? = null, onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, + containerColor: Color, contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { @@ -58,7 +60,8 @@ fun MentionComposable( onEmoteClick = onEmoteClick, onWhisperReply = if (isWhisperTab) onWhisperReply else null, onJumpToMessage = if (!isWhisperTab) onJumpToMessage else null, - contentPadding = contentPadding + contentPadding = contentPadding, + containerColor = containerColor, ) } // CompositionLocalProvider } \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index fbecfaed1..b25a63df5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -14,6 +14,7 @@ import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.replies.RepliesUiState +import androidx.compose.ui.graphics.Color import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore /** @@ -33,6 +34,7 @@ fun RepliesComposable( onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onNotFound: () -> Unit, + containerColor: Color, contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { @@ -52,7 +54,8 @@ fun RepliesComposable( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = { /* no-op for replies */ }, - contentPadding = contentPadding + contentPadding = contentPadding, + containerColor = containerColor, ) } is RepliesUiState.NotFound -> { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt index 37f963127..5dcd24c28 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt @@ -5,12 +5,16 @@ object ChatSearchFilterParser { fun parse(query: String): List { if (query.isBlank()) return emptyList() - return query.trim().split("\\s+".toRegex()).mapNotNull { token -> - parseToken(token) + val tokens = query.trim().split("\\s+".toRegex()) + val lastTokenIncomplete = !query.endsWith(' ') + + return tokens.mapIndexedNotNull { index, token -> + val isBeingTyped = lastTokenIncomplete && index == tokens.lastIndex + parseToken(token, isBeingTyped) } } - private fun parseToken(token: String): ChatSearchFilter? { + private fun parseToken(token: String, isBeingTyped: Boolean): ChatSearchFilter? { if (token.isBlank()) return null val (negate, raw) = extractNegation(token) @@ -21,28 +25,22 @@ object ChatSearchFilterParser { val value = raw.substring(colonIndex + 1) when (prefix) { - "from" -> { - if (value.isNotEmpty()) { - return ChatSearchFilter.Author(name = value, negate = negate) - } + "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) - else -> { - if (value.isNotEmpty()) { - ChatSearchFilter.HasEmote(emoteName = value, negate = negate) - } else { - null - } - } + "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" -> { - if (value.isNotEmpty()) { - return ChatSearchFilter.BadgeFilter(badgeName = value.lowercase(), negate = negate) - } + "badge" -> return when { + isBeingTyped || value.isEmpty() -> null + else -> ChatSearchFilter.BadgeFilter(badgeName = value.lowercase(), negate = negate) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt new file mode 100644 index 000000000..660fce8de --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt @@ -0,0 +1,62 @@ +package com.flxrs.dankchat.chat.search + +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.suggestion.Suggestion +import com.flxrs.dankchat.data.DisplayName + +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/chat/suggestion/Suggestion.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt index 3106a2bd4..b8367fb7f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.chat.suggestion +import androidx.annotation.StringRes import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.twitch.emote.GenericEmote @@ -15,4 +16,8 @@ sealed interface Suggestion { data class CommandSuggestion(val command: String) : Suggestion { override fun toString() = command } + + data class FilterSuggestion(val keyword: String, @StringRes val descriptionRes: Int, val displayText: String? = null) : Suggestion { + override fun toString() = keyword + } } 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 index 1a79e275c..438126d50 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionArrayAdapter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionArrayAdapter.kt @@ -57,6 +57,7 @@ class SuggestionsArrayAdapter( } is Suggestion.CommandSuggestion -> imageView.setImageDrawable(context.getDrawableAndSetSurfaceTint(R.drawable.ic_android)) + is Suggestion.FilterSuggestion -> Unit } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt index abcc0ae61..2f48462ec 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt @@ -33,6 +33,7 @@ class SuggestionProvider( */ fun getSuggestions( inputText: String, + cursorPosition: Int, channel: UserName? ): Flow> { if (inputText.isBlank() || channel == null) { @@ -40,7 +41,7 @@ class SuggestionProvider( } // Extract the current word being typed - val currentWord = extractCurrentWord(inputText) + val currentWord = extractCurrentWord(inputText, cursorPosition) if (currentWord.isBlank() || currentWord.length < MIN_SUGGESTION_CHARS) { return flowOf(emptyList()) } @@ -88,21 +89,17 @@ class SuggestionProvider( /** * Extract the current word being typed from the full input text. - * Assumes space-separated words. + * Only looks backwards from cursor position — returns the text between the last space before cursor and the cursor. */ - private fun extractCurrentWord(text: String): String { - val cursorPos = text.length + internal fun extractCurrentWord(text: String, cursorPosition: Int): String { + val cursorPos = cursorPosition.coerceIn(0, text.length) val separator = ' ' - - // Find start of current word + + // Only look backwards from cursor — the word being actively typed var start = cursorPos while (start > 0 && text[start - 1] != separator) start-- - - // Find end of current word - var end = cursorPos - while (end < text.length && text[end] != separator) end++ - - return text.substring(start, end) + + return text.substring(start, cursorPos) } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index b59d6eb6c..1f3f37342 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -2,10 +2,12 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -19,6 +21,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.utils.compose.rememberRoundedCornerHorizontalPadding @OptIn(ExperimentalFoundationApi::class) @Composable @@ -33,7 +36,7 @@ fun ChatBottomBar( isStreamActive: Boolean, hasStreamData: Boolean, isSheetOpen: Boolean, - inputActions: Set, + inputActions: List, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -45,14 +48,18 @@ fun ChatBottomBar( onChangeRoomState: () -> Unit, onSearchClick: () -> Unit, onNewWhisper: (() -> Unit)?, - onInputActionsChanged: (Set) -> Unit, + onInputActionsChanged: (List) -> Unit, onInputHeightChanged: (Int) -> Unit, + instantHide: Boolean = false, ) { Column(modifier = Modifier.fillMaxWidth()) { AnimatedVisibility( visible = showInput, enter = EnterTransition.None, - exit = slideOutVertically(targetOffsetY = { it }), + exit = when { + instantHide -> ExitTransition.None + else -> slideOutVertically(targetOffsetY = { it }) + }, ) { ChatInputLayout( textFieldState = textFieldState, @@ -95,6 +102,10 @@ fun ChatBottomBar( if (!showInput && !isSheetOpen) { val helperText = inputState.helperText if (!helperText.isNullOrEmpty()) { + val horizontalPadding = when { + isFullscreen -> rememberRoundedCornerHorizontalPadding(fallback = 16.dp) + else -> PaddingValues(horizontal = 16.dp) + } Surface( color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.fillMaxWidth() @@ -107,7 +118,8 @@ fun ChatBottomBar( modifier = Modifier .navigationBarsPadding() .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) + .padding(horizontalPadding) + .padding(vertical = 6.dp) .basicMarquee(), textAlign = TextAlign.Start ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index d8651a174..313f25bc5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.basicMarquee 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 @@ -29,6 +30,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit @@ -49,7 +51,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ListItem +import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface @@ -60,6 +62,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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 @@ -86,6 +89,7 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState import com.flxrs.dankchat.preferences.appearance.InputAction +import sh.calvin.reorderable.ReorderableColumn private const val MAX_INPUT_ACTIONS = 4 @@ -105,7 +109,7 @@ fun ChatInputLayout( isModerator: Boolean, isStreamActive: Boolean, hasStreamData: Boolean, - inputActions: Set, + inputActions: List, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -117,7 +121,7 @@ fun ChatInputLayout( whisperTarget: UserName?, onWhisperDismiss: () -> Unit, onChangeRoomState: () -> Unit, - onInputActionsChanged: (Set) -> Unit, + onInputActionsChanged: (List) -> Unit, onSearchClick: () -> Unit = {}, onNewWhisper: (() -> Unit)? = null, showQuickActions: Boolean = true, @@ -149,6 +153,18 @@ fun ChatInputLayout( defaultColors.disabledContainerColor } + // Filter to actions that would actually render based on current state + val effectiveActions = remember(inputActions, isModerator, hasStreamData, isStreamActive) { + inputActions.filter { action -> + when (action) { + InputAction.Stream -> hasStreamData || isStreamActive + InputAction.RoomState -> isModerator + else -> true + } + } + } + + var visibleActions by remember { mutableStateOf(effectiveActions) } var quickActionsExpanded by remember { mutableStateOf(false) } var showConfigSheet by remember { mutableStateOf(false) } val topEndRadius by animateDpAsState( @@ -303,152 +319,94 @@ fun ChatInputLayout( ) } - // Actions Row - Row( - verticalAlignment = Alignment.CenterVertically, + // Actions Row — uses BoxWithConstraints to hide actions that don't fit + BoxWithConstraints( modifier = Modifier .fillMaxWidth() .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) ) { - // Configurable action icons (enum order) - for (action in InputAction.entries) { - if (action !in inputActions) continue - when (action) { - InputAction.Emotes -> { - IconButton( - onClick = { - if (isEmoteMenuOpen) { - focusRequester.requestFocus() - } - onEmoteClick() - }, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, - contentDescription = stringResource( - if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint - ), - ) - } - } - - InputAction.Search -> { - IconButton( - onClick = onSearchClick, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.message_history), - ) - } - } - - InputAction.History -> { - IconButton( - onClick = onLastMessageClick, - enabled = enabled, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.History, - contentDescription = stringResource(R.string.resume_scroll), - ) - } - } - - InputAction.Stream -> { - if (hasStreamData || isStreamActive) { - IconButton( - onClick = onToggleStream, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, - contentDescription = stringResource(R.string.toggle_stream), - ) - } - } - } - - InputAction.RoomState -> { - if (isModerator) { - IconButton( - onClick = onChangeRoomState, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.Shield, - contentDescription = stringResource(R.string.menu_room_state), - ) - } + val iconSize = 40.dp + // Fixed slots: emote + overflow + send (+ whisper if present) + val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 + val availableForActions = maxWidth - iconSize * fixedSlots + val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) + visibleActions = effectiveActions.take(maxVisibleActions) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + // Emote/Keyboard Button (start-aligned, always visible) + IconButton( + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() } - } + onEmoteClick() + }, + enabled = enabled, + modifier = Modifier.size(iconSize) + ) { + Icon( + imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, + contentDescription = stringResource( + if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint + ), + ) + } - InputAction.Fullscreen -> { - IconButton( - onClick = onToggleFullscreen, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, - contentDescription = stringResource(R.string.toggle_fullscreen), - ) - } - } + Spacer(modifier = Modifier.weight(1f)) - InputAction.HideInput -> { - IconButton( - onClick = onToggleInput, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.VisibilityOff, - contentDescription = stringResource(R.string.menu_hide_input), - ) - } + // Overflow Button (leading the end-aligned group) + if (showQuickActions) { + IconButton( + onClick = { quickActionsExpanded = !quickActionsExpanded }, + modifier = Modifier.size(iconSize) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } - } - - Spacer(modifier = Modifier.weight(1f)) - // Quick Actions / Overflow Button - if (showQuickActions) { - IconButton( - onClick = { quickActionsExpanded = !quickActionsExpanded }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant + // Configurable action icons (only those that fit) + for (action in visibleActions) { + InputActionButton( + action = action, + enabled = enabled, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onChangeRoomState = onChangeRoomState, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + modifier = Modifier.size(iconSize), ) } - } - // New Whisper Button (only on whisper tab) - if (onNewWhisper != null) { - IconButton( - onClick = onNewWhisper, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.whisper_new), - ) + // New Whisper Button (only on whisper tab) + if (onNewWhisper != null) { + IconButton( + onClick = onNewWhisper, + modifier = Modifier.size(iconSize) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.whisper_new), + ) + } } - } - // Send Button (Right) - SendButton( - enabled = canSend, - onSend = onSend, - modifier = Modifier - ) + // Send Button (Right) + SendButton( + enabled = canSend, + onSend = onSend, + ) + } } } } @@ -493,9 +451,9 @@ fun ChatInputLayout( color = surfaceColor, ) { Column(modifier = Modifier.width(IntrinsicSize.Max)) { - // Overflow items: actions NOT in inputActions (with conditions) + // Overflow items: actions NOT visible in the action bar for (action in InputAction.entries) { - if (action in inputActions) continue + if (action in visibleActions) continue val overflowItem = getOverflowItem( action = action, isStreamActive = isStreamActive, @@ -508,9 +466,8 @@ fun ChatInputLayout( text = { Text(stringResource(overflowItem.labelRes)) }, onClick = { when (action) { - InputAction.Emotes -> onEmoteClick() InputAction.Search -> onSearchClick() - InputAction.History -> onLastMessageClick() + InputAction.LastMessage -> onLastMessageClick() InputAction.Stream -> onToggleStream() InputAction.RoomState -> onChangeRoomState() InputAction.Fullscreen -> onToggleFullscreen() @@ -572,18 +529,13 @@ private fun getOverflowItem( isFullscreen: Boolean, isModerator: Boolean, ): OverflowItem? = when (action) { - InputAction.Emotes -> OverflowItem( - labelRes = R.string.input_action_emotes, - icon = Icons.Default.EmojiEmotions, - ) - InputAction.Search -> OverflowItem( labelRes = R.string.input_action_search, icon = Icons.Default.Search, ) - InputAction.History -> OverflowItem( - labelRes = R.string.input_action_history, + InputAction.LastMessage -> OverflowItem( + labelRes = R.string.input_action_last_message, icon = Icons.Default.History, ) @@ -619,15 +571,22 @@ private fun getOverflowItem( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun InputActionConfigSheet( - inputActions: Set, - onInputActionsChanged: (Set) -> Unit, + inputActions: List, + onInputActionsChanged: (List) -> Unit, onDismiss: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val atLimit = inputActions.size >= MAX_INPUT_ACTIONS + + val localEnabled = remember { mutableStateListOf(*inputActions.toTypedArray()) } + + val disabledActions = InputAction.entries.filter { it !in localEnabled } + val atLimit = localEnabled.size >= MAX_INPUT_ACTIONS ModalBottomSheet( - onDismissRequest = onDismiss, + onDismissRequest = { + onInputActionsChanged(localEnabled.toList()) + onDismiss() + }, sheetState = sheetState, ) { Column( @@ -636,56 +595,109 @@ private fun InputActionConfigSheet( .padding(bottom = 16.dp) ) { Text( - text = stringResource(R.string.input_actions_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + text = if (atLimit) stringResource(R.string.input_actions_max, MAX_INPUT_ACTIONS) else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) ) - if (atLimit) { - Text( - text = stringResource(R.string.input_actions_max, MAX_INPUT_ACTIONS), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) - ) - } - - for (action in InputAction.entries) { - val checked = action in inputActions - val actionEnabled = checked || !atLimit - - ListItem( - headlineContent = { Text(stringResource(action.labelRes)) }, - leadingContent = { + // 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), ) - }, - trailingContent = { Checkbox( - checked = checked, - onCheckedChange = null, // handled by row click - enabled = actionEnabled, + 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 { - val newSet = if (checked) { - inputActions - action - } else { - inputActions + action - } - onInputActionsChanged(newSet) - } + 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, + ) + } } } } @@ -693,9 +705,8 @@ private fun InputActionConfigSheet( private val InputAction.labelRes: Int get() = when (this) { - InputAction.Emotes -> R.string.input_action_emotes InputAction.Search -> R.string.input_action_search - InputAction.History -> R.string.input_action_history + InputAction.LastMessage -> R.string.input_action_last_message InputAction.Stream -> R.string.input_action_stream InputAction.RoomState -> R.string.input_action_room_state InputAction.Fullscreen -> R.string.input_action_fullscreen @@ -704,9 +715,8 @@ private val InputAction.labelRes: Int private val InputAction.icon: ImageVector get() = when (this) { - InputAction.Emotes -> Icons.Default.EmojiEmotions InputAction.Search -> Icons.Default.Search - InputAction.History -> Icons.Default.History + InputAction.LastMessage -> Icons.Default.History InputAction.Stream -> Icons.Default.Videocam InputAction.RoomState -> Icons.Default.Shield InputAction.Fullscreen -> Icons.Default.Fullscreen @@ -737,3 +747,46 @@ private fun SendButton( ) } } + +@Composable +private fun InputActionButton( + action: InputAction, + enabled: Boolean, + isStreamActive: Boolean, + isFullscreen: Boolean, + onSearchClick: () -> Unit, + onLastMessageClick: () -> Unit, + onToggleStream: () -> Unit, + onChangeRoomState: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + modifier: Modifier = Modifier, +) { + val (icon, contentDescription, onClick) = when (action) { + InputAction.Search -> Triple(Icons.Default.Search, R.string.message_history, onSearchClick) + InputAction.LastMessage -> Triple(Icons.Default.History, R.string.resume_scroll, onLastMessageClick) + InputAction.Stream -> Triple( + if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + R.string.toggle_stream, + onToggleStream, + ) + InputAction.RoomState -> Triple(Icons.Default.Shield, R.string.menu_room_state, onChangeRoomState) + InputAction.Fullscreen -> Triple( + if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + R.string.toggle_fullscreen, + onToggleFullscreen, + ) + InputAction.HideInput -> Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) + } + + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + ) { + Icon( + imageVector = icon, + contentDescription = stringResource(contentDescription), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index e049d2c58..a23c5b8be 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.ui.text.TextRange import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.chat.suggestion.SuggestionProvider import com.flxrs.dankchat.data.DisplayName @@ -88,20 +89,23 @@ class ChatInputViewModel( private val _whisperTarget = MutableStateFlow(null) val whisperTarget: StateFlow = _whisperTarget.asStateFlow() - // Create flow from TextFieldState + // Create flow from TextFieldState tracking both text and cursor position private val textFlow = snapshotFlow { textFieldState.text.toString() } + private val textAndCursorFlow = snapshotFlow { + textFieldState.text.toString() to textFieldState.selection.start + } - // Debounce text changes for suggestion lookups - private val debouncedText = textFlow.debounce(SUGGESTION_DEBOUNCE_MS) + // Debounce text/cursor changes for suggestion lookups + private val debouncedTextAndCursor = textAndCursorFlow.debounce(SUGGESTION_DEBOUNCE_MS) - // Get suggestions based on current text and active channel + // Get suggestions based on current text, cursor position, and active channel private val suggestions: StateFlow> = combine( - debouncedText, + debouncedTextAndCursor, chatRepository.activeChannel - ) { text, channel -> - text to channel - }.flatMapLatest { (text, channel) -> - suggestionProvider.getSuggestions(text, channel) + ) { (text, cursorPos), channel -> + Triple(text, cursorPos, channel) + }.flatMapLatest { (text, cursorPos, channel) -> + suggestionProvider.getSuggestions(text, cursorPos, channel) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val roomStateDisplayText: StateFlow = combine( @@ -437,21 +441,12 @@ class ChatInputViewModel( */ fun applySuggestion(suggestion: Suggestion) { val currentText = textFieldState.text.toString() - val cursorPos = currentText.length // Assume cursor at end for simplicity - val separator = ' ' - - // Find start of current word - var start = cursorPos - while (start > 0 && currentText[start - 1] != separator) start-- - - // Build new text with replacement - val replacement = suggestion.toString() + separator - val newText = currentText.substring(0, start) + replacement + val cursorPos = textFieldState.selection.start + val result = computeSuggestionReplacement(currentText, cursorPos, suggestion.toString()) - // Replace all text and place cursor at end textFieldState.edit { - replace(0, length, newText) - placeCursorAtEnd() + replace(result.replaceStart, result.replaceEnd, result.replacement) + selection = TextRange(result.newCursorPos) } if (suggestion is Suggestion.EmoteSuggestion) { @@ -478,6 +473,29 @@ class ChatInputViewModel( } } +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, + ) +} + @Immutable data class ChatInputUiState( val text: String = "", diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index d1966d885..f5e15eb18 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -1,15 +1,19 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +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.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.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flxrs.dankchat.chat.compose.BadgeUi @@ -20,10 +24,13 @@ 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.chat.history.compose.MessageHistoryComposeViewModel import com.flxrs.dankchat.main.compose.sheets.MentionSheet import com.flxrs.dankchat.main.compose.sheets.MessageHistorySheet import com.flxrs.dankchat.main.compose.sheets.RepliesSheet import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @Composable fun FullScreenSheetOverlay( @@ -41,11 +48,34 @@ fun FullScreenSheetOverlay( bottomContentPadding: Dp = 0.dp, modifier: Modifier = Modifier, ) { - val bottomContentPaddingPx = with(LocalDensity.current) { bottomContentPadding.roundToPx() } + // Pre-resolve history VM outside AnimatedVisibility to avoid Koin creating + // duplicate instances during the enter animation (causes mid-animation stutter) + val historyState = sheetState as? FullScreenSheetState.History + val currentHistoryViewModel: MessageHistoryComposeViewModel? = historyState?.let { + koinViewModel( + key = it.channel.value, + parameters = { parametersOf(it.channel) }, + ) + } + + // Remember the last active (non-Closed) state and history VM so content persists + // during exit animation. Without this, when sheetState changes to Closed the `when` + // block would render nothing, causing a flash while the exit animation is still playing. + var lastActiveState by remember { mutableStateOf(sheetState) } + var lastHistoryViewModel by remember { mutableStateOf(currentHistoryViewModel) } + if (sheetState !is FullScreenSheetState.Closed) { + lastActiveState = sheetState + } + if (currentHistoryViewModel != null) { + lastHistoryViewModel = currentHistoryViewModel + } + + val isVisible = sheetState !is FullScreenSheetState.Closed + AnimatedVisibility( - visible = sheetState !is FullScreenSheetState.Closed, - enter = slideInVertically(initialOffsetY = { it - bottomContentPaddingPx }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it - bottomContentPaddingPx }) + fadeOut(), + visible = isVisible, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), modifier = modifier.fillMaxSize() ) { Box( @@ -63,7 +93,13 @@ fun FullScreenSheetOverlay( ) } - when (sheetState) { + // Use lastActiveState so content stays visible during the exit animation + val renderState = when { + isVisible -> sheetState + else -> lastActiveState + } + + when (renderState) { is FullScreenSheetState.Closed -> Unit is FullScreenSheetState.Mention -> { MentionSheet( @@ -119,7 +155,7 @@ fun FullScreenSheetOverlay( } is FullScreenSheetState.Replies -> { RepliesSheet( - rootMessageId = sheetState.replyMessageId, + rootMessageId = renderState.replyMessageId, appearanceSettingsDataStore = appearanceSettingsDataStore, onDismiss = onDismissReplies, onUserClick = userClickHandler, @@ -140,8 +176,9 @@ fun FullScreenSheetOverlay( } is FullScreenSheetState.History -> { MessageHistorySheet( - channel = sheetState.channel, - initialFilter = sheetState.initialFilter, + viewModel = (currentHistoryViewModel ?: lastHistoryViewModel)!!, + channel = renderState.channel, + initialFilter = renderState.initialFilter, appearanceSettingsDataStore = appearanceSettingsDataStore, onDismiss = onDismiss, onUserClick = userClickHandler, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 38f843779..25165a095 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -316,6 +316,7 @@ fun MainScreen( val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() val isSheetOpen = fullScreenSheetState !is FullScreenSheetState.Closed + val isHistorySheet = fullScreenSheetState is FullScreenSheetState.History val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() MainScreenEventHandler( @@ -438,8 +439,7 @@ fun MainScreen( ) val gestureInputHidden by mainScreenViewModel.gestureInputHidden.collectAsStateWithLifecycle() val gestureToolbarHidden by mainScreenViewModel.gestureToolbarHidden.collectAsStateWithLifecycle() - val isHistorySheetOpen = fullScreenSheetState is FullScreenSheetState.History - val effectiveShowInput = showInputState && !gestureInputHidden && !isHistorySheetOpen + val effectiveShowInput = showInputState && !gestureInputHidden val effectiveShowAppBar = showAppBar && !gestureToolbarHidden val toolbarTracker = remember { @@ -561,7 +561,7 @@ fun MainScreen( // Shared bottom bar content val bottomBar: @Composable () -> Unit = { ChatBottomBar( - showInput = effectiveShowInput, + showInput = effectiveShowInput && !isHistorySheet, textFieldState = chatInputViewModel.textFieldState, inputState = inputState, isUploading = dialogState.isUploading, @@ -596,6 +596,7 @@ fun MainScreen( } }, onInputHeightChanged = { inputHeightPx = it }, + instantHide = isHistorySheet, ) } @@ -763,6 +764,7 @@ fun MainScreen( showInput = effectiveShowInput, isFullscreen = isFullscreen, hasHelperText = !inputState.helperText.isNullOrEmpty(), + showFabs = !isSheetOpen, onRecover = { if (isFullscreen) mainScreenViewModel.toggleFullscreen() if (!showInputState) mainScreenViewModel.toggleInput() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index 375afafaf..746f78e43 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -1,6 +1,9 @@ package com.flxrs.dankchat.main.compose +import android.content.ClipData +import android.content.ClipboardManager import android.content.res.Resources +import androidx.core.content.getSystemService import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult @@ -43,6 +46,8 @@ fun MainScreenEventHandler( 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), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt index 47fb69824..d95b33f0f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt @@ -11,6 +11,7 @@ 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.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -20,6 +21,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Person import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -29,6 +31,7 @@ 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.unit.dp import coil3.compose.AsyncImage import com.flxrs.dankchat.chat.suggestion.Suggestion @@ -140,6 +143,27 @@ private fun SuggestionItem( style = MaterialTheme.typography.bodyLarge ) } + + is Suggestion.FilterSuggestion -> { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + modifier = Modifier + .size(32.dp) + .padding(end = 12.dp) + ) + 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/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 2f27d8ece..d78609c52 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -33,6 +33,7 @@ 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.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -71,6 +72,11 @@ fun MentionSheet( val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } // Toolbar area: status bar + padding + pill height + padding val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp + val sheetBackgroundColor = lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) LaunchedEffect(pagerState.currentPage) { mentionViewModel.setCurrentTab(pagerState.currentPage) @@ -90,7 +96,7 @@ fun MentionSheet( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + .background(sheetBackgroundColor) .graphicsLayer { val scale = 1f - (backProgress * 0.1f) scaleX = scale @@ -113,6 +119,7 @@ fun MentionSheet( onEmoteClick = onEmoteClick, onWhisperReply = if (page == 1) onWhisperReply else null, onJumpToMessage = if (page == 0) onJumpToMessage else null, + containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), ) } @@ -124,9 +131,9 @@ fun MentionSheet( .fillMaxWidth() .background( brush = Brush.verticalGradient( - 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - 0.75f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0f) + 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) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt index aa7a12d1e..8d734a55e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -15,11 +15,14 @@ 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.text.KeyboardActions 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.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -33,13 +36,13 @@ 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.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -56,15 +59,16 @@ import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.history.compose.MessageHistoryComposeViewModel +import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.main.compose.SuggestionDropdown import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.CancellationException -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf @Composable fun MessageHistorySheet( + viewModel: MessageHistoryComposeViewModel, channel: UserName, initialFilter: String, appearanceSettingsDataStore: AppearanceSettingsDataStore, @@ -74,23 +78,22 @@ fun MessageHistorySheet( onEmoteClick: (List) -> Unit, onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, ) { - val viewModel: MessageHistoryComposeViewModel = koinViewModel( - key = channel.value, - parameters = { parametersOf(channel) }, - ) - LaunchedEffect(initialFilter) { - if (initialFilter.isNotEmpty()) { - viewModel.updateSearchQuery(initialFilter) - } + LaunchedEffect(viewModel, initialFilter) { + viewModel.setInitialQuery(initialFilter) } val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle( initialValue = appearanceSettingsDataStore.current() ) val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) - val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val filterSuggestions by viewModel.filterSuggestions.collectAsStateWithLifecycle() + val sheetBackgroundColor = lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) var backProgress by remember { mutableFloatStateOf(0f) } val density = LocalDensity.current @@ -123,7 +126,7 @@ fun MessageHistorySheet( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + .background(sheetBackgroundColor) .graphicsLayer { val scale = 1f - (backProgress * 0.1f) scaleX = scale @@ -142,6 +145,7 @@ fun MessageHistorySheet( onEmoteClick = onEmoteClick, onJumpToMessage = onJumpToMessage, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), + containerColor = sheetBackgroundColor, ) } @@ -152,9 +156,9 @@ fun MessageHistorySheet( .fillMaxWidth() .background( brush = Brush.verticalGradient( - 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - 0.75f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0f), + 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) @@ -199,29 +203,31 @@ fun MessageHistorySheet( } } - // Bottom search bar with upward gradient scrim + // Filter suggestions above search bar + SuggestionDropdown( + suggestions = filterSuggestions, + 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) - .background( - brush = Brush.verticalGradient( - 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0f), - 0.25f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - ) - ) .navigationBarsPadding() .onGloballyPositioned { coordinates -> searchBarHeightPx = coordinates.size.height } - .padding(top = 16.dp, bottom = 8.dp) + .padding(bottom = 8.dp) .padding(horizontal = 8.dp), ) { SearchToolbar( - searchQuery = searchQuery, - onSearchQueryChange = viewModel::updateSearchQuery, + state = viewModel.searchFieldState, ) } } @@ -229,39 +235,30 @@ fun MessageHistorySheet( @Composable private fun SearchToolbar( - searchQuery: String, - onSearchQueryChange: (String) -> Unit, + state: TextFieldState, ) { - var textState by remember(searchQuery) { mutableStateOf(searchQuery) } val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(searchQuery) { - if (textState != searchQuery) { - textState = searchQuery - } - } - val textFieldColors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ) TextField( - value = textState, - onValueChange = { - textState = it - onSearchQueryChange(it) - }, + state = state, modifier = Modifier.fillMaxWidth(), placeholder = { Text(stringResource(R.string.search_messages_hint)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, trailingIcon = { - if (textState.isNotEmpty()) { - IconButton(onClick = { - textState = "" - onSearchQueryChange("") - }) { + if (state.text.isNotEmpty()) { + IconButton(onClick = { state.clearText() }) { Icon( imageVector = Icons.Default.Clear, contentDescription = stringResource(R.string.dialog_dismiss), @@ -269,9 +266,9 @@ private fun SearchToolbar( } } }, - singleLine = true, + lineLimits = TextFieldLineLimits.SingleLine, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), + onKeyboardAction = { keyboardController?.hide() }, shape = MaterialTheme.shapes.extraLarge, colors = textFieldColors, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index 8f983b9d8..dbdedc3fa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -26,6 +26,7 @@ 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.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -57,6 +58,11 @@ fun RepliesSheet( 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, + ) PredictiveBackHandler { progress -> try { @@ -72,7 +78,7 @@ fun RepliesSheet( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + .background(sheetBackgroundColor) .graphicsLayer { val scale = 1f - (backProgress * 0.1f) scaleX = scale @@ -88,6 +94,7 @@ fun RepliesSheet( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onNotFound = onDismiss, + containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), modifier = Modifier.fillMaxSize(), ) @@ -99,9 +106,9 @@ fun RepliesSheet( .fillMaxWidth() .background( brush = Brush.verticalGradient( - 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - 0.75f to MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0f) + 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) 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 1460c7b16..1528995a6 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 @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable enum class InputAction { - Emotes, Search, History, Stream, RoomState, Fullscreen, HideInput + Search, LastMessage, Stream, RoomState, Fullscreen, HideInput } @Serializable @@ -19,9 +19,9 @@ data class AppearanceSettings( val autoDisableInput: Boolean = true, val showChips: Boolean = true, val showChangelogs: Boolean = true, - val inputActions: Set = setOf( - InputAction.Emotes, InputAction.Search, InputAction.History, + val inputActions: List = listOf( InputAction.Stream, InputAction.RoomState, + InputAction.Search, InputAction.LastMessage, ), ) 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 index f435caad7..a6e676259 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -132,6 +132,40 @@ fun rememberRoundedCornerBottomPadding(fallback: Dp = 0.dp): Dp { return with(density) { maxRadius.toDp() } } +/** + * 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 + val start = with(density) { bottomLeft.center.x.toDp() } + val end = with(density) { (screenWidth - bottomRight.center.x).toDp() } + + return PaddingValues(start = start, end = end) +} + @RequiresApi(api = 31) private fun RoundedCorner.calculateTopPaddingForComponent( componentX: Int, diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index b07ba9638..2882c94e7 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -357,6 +357,12 @@ Гісторыя паведамленняў Гісторыя: %1$s Пошук паведамленняў… + Фільтр па імені карыстальніка + Паведамленні са спасылкамі + Паведамленні з эмоўтамі + Фільтр па назве значка + Карыстальнік + Значок У адказ @%1$s Галіна адказаў не знойдзена Паведамленне не знойдзена diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ccbe16b86..a686a0e63 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -305,4 +305,10 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader El missatge ja no és a l\'historial del xat Historial de missatges Historial: %1$s - Cerca missatges… + Cerca missatges… + Filtra per nom d\'usuari + Missatges amb enllaços + Missatges amb emotes + Filtra per nom d\'insígnia + Usuari + Insígnia diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index deb5a5475..074943081 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -364,6 +364,12 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Historie zpráv 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index f7fb236b6..882bfb8c7 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -367,6 +367,12 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Nachrichtenhistorie 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 5bd294e98..f63d67aca 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -242,4 +242,10 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Message no longer in chat history Message history History: %1$s - Search messages… + Search messages… + Filter by username + Messages containing links + Messages containing emotes + Filter by badge name + User + Badge diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index aa32bfcf4..3cbb9571e 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -243,4 +243,10 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Message no longer in chat history Message history History: %1$s - Search messages… + Search messages… + Filter by username + Messages containing links + Messages containing emotes + Filter by badge name + User + Badge diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index bc184254e..cab3934e0 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -360,6 +360,12 @@ Message 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 1872e36b2..e3cc4458c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -366,6 +366,12 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Historial de mensajes 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 45396506b..ddc69a73d 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -269,4 +269,10 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Viesti ei ole enää chat-historiassa Viestihistoria Historia: %1$s - Hae viestejä… + 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 38d7800df..79f12e169 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -362,6 +362,12 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Historique des messages 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é diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 90c3bbe05..69a053e4c 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -357,6 +357,12 @@ Üzenetelő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ó diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 400857f37..00b071437 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -356,6 +356,12 @@ Cronologia messaggi 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index ac77054a3..d3f7b2483 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -356,6 +356,12 @@ メッセージ履歴 履歴:%1$s メッセージを検索… + ユーザー名でフィルター + リンクを含むメッセージ + エモートを含むメッセージ + バッジ名でフィルター + ユーザー + バッジ \@%1$sに返信 返信スレッドが見つかりません メッセージが見つかりません diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 77542bc2c..86d4941fe 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -364,6 +364,12 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Historia wiadomości 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4904cdf77..93a2d2f56 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -357,6 +357,12 @@ Histórico de mensagens 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 9981784d5..b74a3fcc5 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -357,6 +357,12 @@ Histórico de mensagens 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 595a58ee8..30d9d46a2 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -362,6 +362,12 @@ История сообщений История: %1$s Поиск сообщений… + Фильтр по имени пользователя + Сообщения со ссылками + Сообщения с эмоутами + Фильтр по названию значка + Пользователь + Значок В ответ @%1$s Ветка ответов не найдена Сообщение не найдено diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 64b8dc711..86c2f48f6 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -210,4 +210,10 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Порука више није у историји ћаскања Историја порука Историја: %1$s - Претражи поруке… + Претражи поруке… + Филтрирај по корисничком имену + Поруке са линковима + Поруке са емотима + Филтрирај по називу значке + Корисник + Значка diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index ff8061238..fc0920fcd 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -366,6 +366,12 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Mesaj geçmişi 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ı diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 630cb990f..2d5d707c6 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -364,6 +364,12 @@ Історія повідомлень Історія: %1$s Пошук повідомлень… + Фільтр за іменем користувача + Повідомлення з посиланнями + Повідомлення з емоутами + Фільтр за назвою значка + Користувач + Значок Відповісти @%1$s Тема відповіді не знайдена Повідомлення не знайдено diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e40de88e4..d24b3c864 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,17 +139,16 @@ Fullscreen Exit fullscreen Hide input -Room state +Channel settings Input actions Maximum of %1$d actions - Emote menu Search messages - Last message + Last message Toggle stream - Room state + Channel settings Fullscreen Hide input - Configure actions… + Configure actions Emote only Subscriber only Slow mode @@ -452,6 +451,12 @@ Message 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 Whispering @%1$s Send a whisper diff --git a/app/src/test/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProviderExtractWordTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProviderExtractWordTest.kt new file mode 100644 index 000000000..61e69ae49 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProviderExtractWordTest.kt @@ -0,0 +1,101 @@ +package com.flxrs.dankchat.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(), + chatSettingsDataStore = 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/main/compose/SuggestionReplacementTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/main/compose/SuggestionReplacementTest.kt new file mode 100644 index 000000000..ac024c535 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/main/compose/SuggestionReplacementTest.kt @@ -0,0 +1,94 @@ +package com.flxrs.dankchat.main.compose + +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) + } +} From 32ad169d0207678541965da93a0f5db4a3334715 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 13:26:26 +0100 Subject: [PATCH 030/349] fix(compose): Settings propagation and display fixes --- .../chat/compose/AdaptiveTextColor.kt | 36 +++++++++++-------- .../dankchat/chat/compose/BackgroundColor.kt | 30 +++++++++++++++- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt index 56502e904..8ab542856 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt @@ -4,33 +4,41 @@ 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 com.flxrs.dankchat.theme.LocalAdaptiveColors import com.flxrs.dankchat.utils.extensions.normalizeColor import com.google.android.material.color.MaterialColors +/** + * 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 MaterialColors.isColorLight() 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 surfaceColor = MaterialTheme.colorScheme.surface - - // For transparent backgrounds, use surface color for calculation - val effectiveBackground = if (backgroundColor == Color.Transparent) { - surfaceColor - } else { - backgroundColor - } - + val effectiveBackground = resolveEffectiveBackground(backgroundColor) + val isLightBackground = MaterialColors.isColorLight(effectiveBackground.toArgb()) - + return if (isLightBackground) { adaptiveColors.onSurfaceLight } else { @@ -40,12 +48,12 @@ fun rememberAdaptiveTextColor(backgroundColor: Color): Color { /** * Normalizes a raw color int for readable contrast against the effective background. - * Uses [MaterialTheme.colorScheme.surface] when the background is transparent. + * 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 surfaceColor = MaterialTheme.colorScheme.surface - val effectiveBg = if (backgroundColor == Color.Transparent) surfaceColor else backgroundColor + val effectiveBg = resolveEffectiveBackground(backgroundColor) val effectiveBgArgb = effectiveBg.toArgb() return remember(rawColor, effectiveBgArgb) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt index fb2f15c9d..2c36aa5e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt @@ -1,13 +1,41 @@ package com.flxrs.dankchat.chat.compose 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 +import com.google.android.material.color.MaterialColors /** * 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 { - return if (isSystemInDarkTheme()) darkColor else lightColor + 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 + } + } +} + +/** + * Returns the opaque checkered background color for the current theme. + * Composites [inverseSurface] at low alpha over [background], matching the + * old adapter's [MaterialColors.layer] behavior. + */ +@Composable +fun rememberCheckeredBackgroundColor(): Color { + val background = MaterialTheme.colorScheme.background + val inverseSurface = MaterialTheme.colorScheme.inverseSurface + return remember(background, inverseSurface) { + inverseSurface.copy(alpha = MaterialColors.ALPHA_DISABLED_LOW).compositeOver(background) + } } From d54ced204544e6b475355065fe362fc7b5016716 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 13:26:33 +0100 Subject: [PATCH 031/349] fix(compose): Pass lineSeparator and animateGifs settings to fullscreen sheets --- .../dankchat/chat/mention/compose/MentionComposable.kt | 6 ++++++ .../dankchat/chat/replies/compose/RepliesComposable.kt | 6 ++++++ .../dankchat/main/compose/sheets/MessageHistorySheet.kt | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index f8d8abf93..bac0daa9d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -16,6 +16,8 @@ import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import androidx.compose.ui.graphics.Color import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import org.koin.compose.koinInject /** * Standalone composable for mentions/whispers display. @@ -40,7 +42,9 @@ fun MentionComposable( contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { + val chatSettingsDataStore: ChatSettingsDataStore = koinInject() val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) + val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) val messages by when { isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) @@ -53,6 +57,8 @@ fun MentionComposable( ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), + showLineSeparator = appearanceSettings.lineSeparator, + animateGifs = chatSettings.animateGifs, showChannelPrefix = !isWhisperTab, modifier = modifier, onUserClick = onUserClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index b25a63df5..a466833ea 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -16,6 +16,8 @@ import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.replies.RepliesUiState import androidx.compose.ui.graphics.Color import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import org.koin.compose.koinInject /** * Standalone composable for reply thread display. @@ -38,7 +40,9 @@ fun RepliesComposable( contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { + val chatSettingsDataStore: ChatSettingsDataStore = koinInject() val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) + val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) val context = LocalPlatformContext.current @@ -50,6 +54,8 @@ fun RepliesComposable( ChatScreen( messages = (uiState as RepliesUiState.Found).items, fontSize = appearanceSettings.fontSize.toFloat(), + showLineSeparator = appearanceSettings.lineSeparator, + animateGifs = chatSettings.animateGifs, modifier = modifier, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt index 8d734a55e..b7bc81284 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -64,7 +64,9 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.compose.SuggestionDropdown import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.CancellationException +import org.koin.compose.koinInject @Composable fun MessageHistorySheet( @@ -83,9 +85,11 @@ fun MessageHistorySheet( viewModel.setInitialQuery(initialFilter) } + val chatSettingsDataStore: ChatSettingsDataStore = koinInject() val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle( initialValue = appearanceSettingsDataStore.current() ) + val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val filterSuggestions by viewModel.filterSuggestions.collectAsStateWithLifecycle() @@ -139,6 +143,8 @@ fun MessageHistorySheet( ChatScreen( messages = messages, fontSize = appearanceSettings.fontSize.toFloat(), + showLineSeparator = appearanceSettings.lineSeparator, + animateGifs = chatSettings.animateGifs, modifier = Modifier.fillMaxSize(), onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, From 12acad0216191a484468cedd3a2b6724761a2236 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 13:26:39 +0100 Subject: [PATCH 032/349] fix: Disable navigation bar contrast enforcement for correct tint with 3-button nav --- app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 85be559ad..14443d1fb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -175,6 +175,7 @@ class MainActivity : AppCompatActivity() { } enableEdgeToEdge() + window.isNavigationBarContrastEnforced = false super.onCreate(savedInstanceState) From a51609004e4907875b9ddc70821e88707132b3ac Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 13:34:39 +0100 Subject: [PATCH 033/349] fix(compose): Apply checkered message backgrounds in fullscreen sheets --- .../history/compose/MessageHistoryComposeViewModel.kt | 8 +++++--- .../chat/mention/compose/MentionComposeViewModel.kt | 11 +++++++---- .../chat/replies/compose/RepliesComposeViewModel.kt | 6 ++++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt index acc0d51a9..7d4f72e1d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take +import com.flxrs.dankchat.utils.extensions.isEven import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @@ -69,13 +70,14 @@ class MessageHistoryComposeViewModel( ) { messages, activeFilters, appearanceSettings, chatSettings -> messages .filter { ChatItemFilter.matches(it, activeFilters) } - .map { - it.toChatMessageUiState( + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + item.toChatMessageUiState( context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, - isAlternateBackground = false, + isAlternateBackground = altBg, ) } }.flowOn(Dispatchers.Default) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt index a344d5c7f..60858f734 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import com.flxrs.dankchat.utils.extensions.isEven import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @@ -48,13 +49,14 @@ class MentionComposeViewModel( appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, ) { messages, appearanceSettings, chatSettings -> - messages.map { item -> + messages.mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages item.toChatMessageUiState( context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, - isAlternateBackground = false + isAlternateBackground = altBg ) } }.flowOn(Dispatchers.Default) @@ -64,13 +66,14 @@ class MentionComposeViewModel( appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, ) { messages, appearanceSettings, chatSettings -> - messages.map { item -> + messages.mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages item.toChatMessageUiState( context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, - isAlternateBackground = false + isAlternateBackground = altBg ) } }.flowOn(Dispatchers.Default) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt index 9ee711b05..8cb001397 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel +import com.flxrs.dankchat.utils.extensions.isEven import org.koin.core.annotation.InjectedParam import kotlin.time.Duration.Companion.seconds @@ -48,13 +49,14 @@ class RepliesComposeViewModel( when (repliesState) { is RepliesState.NotFound -> RepliesUiState.NotFound is RepliesState.Found -> { - val uiMessages = repliesState.items.map { item -> + val uiMessages = repliesState.items.mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages item.toChatMessageUiState( context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, - isAlternateBackground = false + isAlternateBackground = altBg ) } RepliesUiState.Found(uiMessages) From 008ecf9a33482123e25c4b3fa24daa89858bbc90 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 14:23:22 +0100 Subject: [PATCH 034/349] fix(compose): Apply font size setting to all message body text --- .../dankchat/chat/compose/TextWithMeasuredInlineContent.kt | 3 +++ .../com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt | 2 ++ .../dankchat/chat/compose/messages/WhisperAndRedemption.kt | 4 ++++ .../chat/compose/messages/common/SimpleMessageContainer.kt | 2 ++ 4 files changed, 11 insertions(+) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index 5549233e3..1a3736658 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -19,6 +19,7 @@ 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.sp import kotlinx.coroutines.launch @@ -59,6 +60,7 @@ fun TextWithMeasuredInlineContent( text: AnnotatedString, inlineContentProviders: Map Unit>, modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, knownDimensions: Map = emptyMap(), onTextClick: ((Int) -> Unit)? = null, onTextLongClick: (() -> Unit)? = null, @@ -116,6 +118,7 @@ fun TextWithMeasuredInlineContent( val textMeasurables = subcompose("text") { BasicText( text = text, + style = style, inlineContent = inlineContent, modifier = Modifier.pointerInput(text, interactionSource) { detectTapGestures( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 1b13492d6..4598b6b52 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -300,6 +301,7 @@ private fun PrivMessageText( TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, + style = TextStyle(fontSize = fontSize.sp), knownDimensions = knownDimensions, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 5cc22c236..7f30238ec 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.platform.LocalDensity 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.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -274,6 +275,7 @@ private fun WhisperMessageText( TextWithMeasuredInlineContent( text = annotatedString, inlineContentProviders = inlineContentProviders, + style = TextStyle(fontSize = fontSize.sp), knownDimensions = knownDimensions, modifier = Modifier.fillMaxWidth(), onTextClick = { offset -> @@ -372,6 +374,7 @@ fun PointRedemptionMessageComposable( BasicText( text = annotatedString, + style = TextStyle(fontSize = fontSize.sp), modifier = Modifier.weight(1f) ) @@ -383,6 +386,7 @@ fun PointRedemptionMessageComposable( BasicText( text = " ${message.cost}", + style = TextStyle(fontSize = fontSize.sp), modifier = Modifier.padding(start = 4.dp) ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt index 1a7212807..ed3b3d49d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext 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 @@ -80,6 +81,7 @@ fun SimpleMessageContainer( ) { ClickableText( text = annotatedString, + style = TextStyle(fontSize = fontSize), modifier = Modifier.fillMaxWidth(), onClick = { offset -> annotatedString.getStringAnnotations("URL", offset, offset) From 9813cf18712a86cdd1b3100ea7a9e0a63782dc5e Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 14:23:29 +0100 Subject: [PATCH 035/349] fix(compose): Hide chip actions setting in Compose mode and remove changelogs setting --- .../appearance/AppearanceSettingsScreen.kt | 30 +++++++++---------- .../appearance/AppearanceSettingsViewModel.kt | 22 ++++++++++---- 2 files changed, 31 insertions(+), 21 deletions(-) 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 index 60a12f0ab..28dabe1c1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -45,7 +45,6 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.C 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 @@ -66,10 +65,11 @@ fun AppearanceSettingsScreen( onBackPressed: () -> Unit, ) { val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value + val uiState = viewModel.settings.collectAsStateWithLifecycle().value AppearanceSettingsContent( - settings = settings, + settings = uiState.settings, + useComposeUi = uiState.useComposeUi, onInteraction = { viewModel.onInteraction(it) }, onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, onBackPressed = onBackPressed @@ -79,6 +79,7 @@ fun AppearanceSettingsScreen( @Composable private fun AppearanceSettingsContent( settings: AppearanceSettings, + useComposeUi: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit, onSuspendingInteraction: suspend (AppearanceSettingsInteraction) -> Unit, onBackPressed: () -> Unit, @@ -124,7 +125,7 @@ private fun AppearanceSettingsContent( showInput = settings.showInput, autoDisableInput = settings.autoDisableInput, showChips = settings.showChips, - showChangelogs = settings.showChangelogs, + showChipsSetting = !useComposeUi, onInteraction = onInteraction, ) NavigationBarSpacer() @@ -137,7 +138,7 @@ private fun ComponentsCategory( showInput: Boolean, autoDisableInput: Boolean, showChips: Boolean, - showChangelogs: Boolean, + showChipsSetting: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit, ) { PreferenceCategory( @@ -155,17 +156,14 @@ private fun ComponentsCategory( 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)) }, - ) + if (showChipsSetting) { + 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)) }, + ) + } } } 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..e85ff8ae5 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 @@ -2,8 +2,10 @@ package com.flxrs.dankchat.preferences.appearance import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore 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 @@ -12,13 +14,18 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class AppearanceSettingsViewModel( private val dataStore: AppearanceSettingsDataStore, + developerSettingsDataStore: DeveloperSettingsDataStore, ) : ViewModel() { - val settings = dataStore.settings.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = dataStore.current(), - ) + private val useComposeUi = developerSettingsDataStore.current().useComposeChatUi + + val settings = dataStore.settings + .map { AppearanceSettingsUiState(settings = it, useComposeUi = useComposeUi) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = AppearanceSettingsUiState(settings = dataStore.current(), useComposeUi = useComposeUi), + ) suspend fun onSuspendingInteraction(interaction: AppearanceSettingsInteraction) { runCatching { @@ -52,3 +59,8 @@ sealed interface AppearanceSettingsInteraction { data class ShowChips(val value: Boolean) : AppearanceSettingsInteraction data class ShowChangelogs(val value: Boolean) : AppearanceSettingsInteraction } + +data class AppearanceSettingsUiState( + val settings: AppearanceSettings, + val useComposeUi: Boolean, +) From 0e134822f43e49a2cb1aa86ec2a541341e33bf80 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 14:30:17 +0100 Subject: [PATCH 036/349] fix(compose): Hide show input setting in Compose mode --- .../appearance/AppearanceSettingsScreen.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 index 28dabe1c1..28b903312 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -125,6 +125,7 @@ private fun AppearanceSettingsContent( showInput = settings.showInput, autoDisableInput = settings.autoDisableInput, showChips = settings.showChips, + showInputSetting = !useComposeUi, showChipsSetting = !useComposeUi, onInteraction = onInteraction, ) @@ -138,21 +139,24 @@ private fun ComponentsCategory( showInput: Boolean, autoDisableInput: Boolean, showChips: Boolean, + showInputSetting: Boolean, showChipsSetting: 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)) }, - ) + if (showInputSetting) { + 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, + isEnabled = !showInputSetting || showInput, isChecked = autoDisableInput, onClick = { onInteraction(AutoDisableInput(it)) }, ) From 6a1e99f7c9f2a0512cbcfe706c160fe50d90ac96 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 19:57:39 +0100 Subject: [PATCH 037/349] feat(compose): Add post-onboarding tour with tooltip walkthrough --- .../dankchat/chat/compose/ChatComposable.kt | 9 + .../flxrs/dankchat/chat/compose/ChatScreen.kt | 45 +- .../dankchat/login/compose/LoginScreen.kt | 20 +- .../com/flxrs/dankchat/main/MainActivity.kt | 37 +- .../flxrs/dankchat/main/MainDestination.kt | 3 + .../dankchat/main/compose/ChatBottomBar.kt | 18 +- .../dankchat/main/compose/ChatInputLayout.kt | 421 ++++++++++++---- .../main/compose/EmptyStateContent.kt | 74 +-- .../dankchat/main/compose/FloatingToolbar.kt | 102 +++- .../flxrs/dankchat/main/compose/MainScreen.kt | 78 ++- .../main/compose/MainScreenViewModel.kt | 7 - .../onboarding/OnboardingDataStore.kt | 63 +++ .../dankchat/onboarding/OnboardingScreen.kt | 453 ++++++++++++++++++ .../dankchat/onboarding/OnboardingSettings.kt | 13 + .../onboarding/OnboardingViewModel.kt | 69 +++ .../developer/DeveloperSettings.kt | 2 +- .../dankchat/tour/FeatureTourController.kt | 166 +++++++ .../tour/PostOnboardingCoordinator.kt | 91 ++++ .../drawable/ic_dank_chat_mono_cropped.xml | 50 ++ app/src/main/res/values-be-rBY/strings.xml | 9 + app/src/main/res/values-ca/strings.xml | 11 +- app/src/main/res/values-cs/strings.xml | 9 + app/src/main/res/values-de-rDE/strings.xml | 9 + app/src/main/res/values-en-rAU/strings.xml | 11 +- app/src/main/res/values-en-rGB/strings.xml | 11 +- app/src/main/res/values-en/strings.xml | 9 + app/src/main/res/values-es-rES/strings.xml | 9 + app/src/main/res/values-fi-rFI/strings.xml | 11 +- app/src/main/res/values-fr-rFR/strings.xml | 9 + app/src/main/res/values-hu-rHU/strings.xml | 9 + app/src/main/res/values-it/strings.xml | 9 + app/src/main/res/values-ja-rJP/strings.xml | 9 + app/src/main/res/values-pl-rPL/strings.xml | 9 + app/src/main/res/values-pt-rBR/strings.xml | 9 + app/src/main/res/values-pt-rPT/strings.xml | 9 + app/src/main/res/values-ru-rRU/strings.xml | 9 + app/src/main/res/values-sr/strings.xml | 11 +- app/src/main/res/values-tr-rTR/strings.xml | 9 + app/src/main/res/values-uk-rUA/strings.xml | 9 + app/src/main/res/values/strings.xml | 39 ++ 40 files changed, 1778 insertions(+), 172 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingSettings.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt create mode 100644 app/src/main/res/drawable/ic_dank_chat_mono_cropped.xml diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 1b2b4bb5c..86d89423a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -2,6 +2,8 @@ package com.flxrs.dankchat.chat.compose 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.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -27,6 +29,7 @@ import org.koin.core.parameter.parametersOf * - Collects settings from data stores * - Renders ChatScreen with all event handlers */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatComposable( channel: UserName, @@ -46,6 +49,9 @@ fun ChatComposable( onScrollDirectionChanged: (Boolean) -> Unit = {}, scrollToMessageId: String? = null, onScrollToMessageHandled: () -> Unit = {}, + recoveryFabTooltipState: TooltipState? = null, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, ) { // Create ChatComposeViewModel with channel-specific key for proper scoping val viewModel: ChatComposeViewModel = koinViewModel( @@ -86,6 +92,9 @@ fun ChatComposable( onScrollDirectionChanged = onScrollDirectionChanged, scrollToMessageId = scrollToMessageId, onScrollToMessageHandled = onScrollToMessageHandled, + recoveryFabTooltipState = recoveryFabTooltipState, + onTourAdvance = onTourAdvance, + onTourSkip = onTourSkip, ) } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 0401d06e9..e1ca4c1b5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -53,8 +53,14 @@ import com.flxrs.dankchat.chat.compose.messages.PrivMessageComposable import com.flxrs.dankchat.chat.compose.messages.SystemMessageComposable import com.flxrs.dankchat.chat.compose.messages.UserNoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.WhisperMessageComposable +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipState import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.main.compose.TourTooltip /** * Main composable for rendering chat messages in a scrollable list. @@ -65,6 +71,7 @@ import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote * - FAB to manually scroll to bottom * - Efficient recomposition with stable keys */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( messages: List, @@ -91,6 +98,9 @@ fun ChatScreen( onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, containerColor: Color = MaterialTheme.colorScheme.background, showFabs: Boolean = true, + recoveryFabTooltipState: TooltipState? = null, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, ) { val listState = rememberLazyListState() @@ -213,12 +223,35 @@ fun ChatScreen( .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + fabBottomPadding), contentAlignment = Alignment.BottomEnd ) { - RecoveryFab( - isFullscreen = isFullscreen, - showInput = showInput, - onRecover = onRecover, - modifier = Modifier.padding(bottom = recoveryBottomPadding) - ) + if (recoveryFabTooltipState != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + TourTooltip( + text = stringResource(R.string.tour_recovery_fab), + onAction = { onTourAdvance?.invoke() }, + onSkip = { onTourSkip?.invoke() }, + isLast = true, + ) + }, + state = recoveryFabTooltipState, + hasAction = true, + ) { + RecoveryFab( + isFullscreen = isFullscreen, + showInput = showInput, + onRecover = onRecover, + modifier = Modifier.padding(bottom = recoveryBottomPadding) + ) + } + } else { + RecoveryFab( + isFullscreen = isFullscreen, + showInput = showInput, + onRecover = onRecover, + modifier = Modifier.padding(bottom = recoveryBottomPadding) + ) + } AnimatedVisibility( visible = showScrollFab, enter = scaleIn() + fadeIn(), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt index 305e264b9..50e5c33e1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt @@ -17,6 +17,8 @@ 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 @@ -48,6 +50,8 @@ fun LoginScreen( ) { 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 -> @@ -66,7 +70,19 @@ fun LoginScreen( 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 -> @@ -77,7 +93,7 @@ fun LoginScreen( AndroidView( factory = { context -> - WebView(context).apply { + WebView(context).also { webViewRef = it }.apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 14443d1fb..16bd8bae6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -59,6 +59,8 @@ import com.flxrs.dankchat.databinding.MainActivityBinding import com.flxrs.dankchat.login.compose.LoginScreen import com.flxrs.dankchat.main.compose.MainScreen import com.flxrs.dankchat.main.compose.MainEventBus +import com.flxrs.dankchat.onboarding.OnboardingDataStore +import com.flxrs.dankchat.onboarding.OnboardingScreen import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.about.AboutScreen import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore @@ -104,6 +106,7 @@ class MainActivity : AppCompatActivity() { private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() private val dankChatPreferenceStore: DankChatPreferenceStore by inject() private val mainEventBus: MainEventBus by inject() + private val onboardingDataStore: OnboardingDataStore by inject() private val dataRepository: DataRepository by inject() private val pendingChannelsToClear = mutableListOf() private var navController: NavController? = null @@ -179,8 +182,9 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) - // Check if we should use Compose UI - val useComposeUi = developerSettingsDataStore.current().useComposeChatUi + // Check if we should use Compose UI — also force Compose for onboarding + val onboardingCompleted = onboardingDataStore.current().hasCompletedOnboarding + val useComposeUi = developerSettingsDataStore.current().useComposeChatUi || !onboardingCompleted if (useComposeUi) { setupComposeUi() @@ -238,10 +242,34 @@ class MainActivity : AppCompatActivity() { initialValue = dankChatPreferenceStore.isLoggedIn ) + val onboardingCompleted = onboardingDataStore.current().hasCompletedOnboarding + NavHost( navController = navController, - startDestination = Main + 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)) } + ) { backStackEntry -> + val loginSuccess = backStackEntry + .savedStateHandle + .get("login_success") == true + + OnboardingScreen( + onNavigateToLogin = { + navController.navigate(Login) + }, + onComplete = { + navController.navigate(Main) { + popUpTo(Onboarding) { inclusive = true } + } + }, + loginSuccess = loginSuccess, + ) + } composable
( enterTransition = { slideInHorizontally(initialOffsetX = { -it }) }, exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) }, @@ -544,7 +572,8 @@ class MainActivity : AppCompatActivity() { viewModel.reconnectIfNecessary() } - val needsNotificationPermission = isAtLeastTiramisu && hasPermission(Manifest.permission.POST_NOTIFICATIONS) + val hasCompletedOnboarding = onboardingDataStore.current().hasCompletedOnboarding + val needsNotificationPermission = hasCompletedOnboarding && isAtLeastTiramisu && hasPermission(Manifest.permission.POST_NOTIFICATIONS) when { needsNotificationPermission -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) // start service without notification permission diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt index 0785fc5f8..ede0c93e2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt @@ -55,3 +55,6 @@ object EmoteMenu @Serializable object Login + +@Serializable +object Onboarding diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index 1f3f37342..f461259f3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -12,9 +12,11 @@ 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.material3.TooltipState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned @@ -23,7 +25,7 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.preferences.appearance.InputAction import com.flxrs.dankchat.utils.compose.rememberRoundedCornerHorizontalPadding -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ChatBottomBar( showInput: Boolean, @@ -51,6 +53,13 @@ fun ChatBottomBar( onInputActionsChanged: (List) -> Unit, onInputHeightChanged: (Int) -> Unit, instantHide: Boolean = false, + inputActionsTooltipState: TooltipState? = null, + overflowMenuTooltipState: TooltipState? = null, + configureActionsTooltipState: TooltipState? = null, + swipeGestureTooltipState: TooltipState? = null, + forceOverflowOpen: Boolean = false, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, ) { Column(modifier = Modifier.fillMaxWidth()) { AnimatedVisibility( @@ -92,6 +101,13 @@ fun ChatBottomBar( onSearchClick = onSearchClick, onNewWhisper = onNewWhisper, showQuickActions = !isSheetOpen, + inputActionsTooltipState = inputActionsTooltipState, + overflowMenuTooltipState = overflowMenuTooltipState, + configureActionsTooltipState = configureActionsTooltipState, + swipeGestureTooltipState = swipeGestureTooltipState, + forceOverflowOpen = forceOverflowOpen, + onTourAdvance = onTourAdvance, + onTourSkip = onTourSkip, modifier = Modifier.onGloballyPositioned { coordinates -> onInputHeightChanged(coordinates.size.height) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 313f25bc5..7583ef2f4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -23,6 +23,7 @@ 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.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits @@ -48,6 +49,13 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.RichTooltip +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.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -60,6 +68,7 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -81,10 +90,15 @@ 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.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties +import androidx.compose.foundation.Canvas +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalDensity import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState @@ -125,6 +139,13 @@ fun ChatInputLayout( onSearchClick: () -> Unit = {}, onNewWhisper: (() -> Unit)? = null, showQuickActions: Boolean = true, + inputActionsTooltipState: TooltipState? = null, + overflowMenuTooltipState: TooltipState? = null, + configureActionsTooltipState: TooltipState? = null, + swipeGestureTooltipState: TooltipState? = null, + forceOverflowOpen: Boolean = false, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } @@ -165,14 +186,15 @@ fun ChatInputLayout( } var visibleActions by remember { mutableStateOf(effectiveActions) } - var quickActionsExpanded by remember { mutableStateOf(false) } + var userExpandedMenu by remember { mutableStateOf(false) } + val quickActionsExpanded = userExpandedMenu || forceOverflowOpen var showConfigSheet by remember { mutableStateOf(false) } val topEndRadius by animateDpAsState( targetValue = if (quickActionsExpanded) 0.dp else 24.dp, label = "topEndCornerRadius" ) - Box(modifier = modifier.fillMaxWidth()) { + val inputContent: @Composable () -> Unit = { Surface( shape = RoundedCornerShape(topStart = 24.dp, topEnd = topEndRadius), color = surfaceColor, @@ -320,94 +342,150 @@ fun ChatInputLayout( } // Actions Row — uses BoxWithConstraints to hide actions that don't fit - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) - ) { - val iconSize = 40.dp - // Fixed slots: emote + overflow + send (+ whisper if present) - val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 - val availableForActions = maxWidth - iconSize * fixedSlots - val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) - visibleActions = effectiveActions.take(maxVisibleActions) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + val actionsRowContent: @Composable () -> Unit = { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) ) { - // Emote/Keyboard Button (start-aligned, always visible) - IconButton( - onClick = { - if (isEmoteMenuOpen) { - focusRequester.requestFocus() - } - onEmoteClick() - }, - enabled = enabled, - modifier = Modifier.size(iconSize) - ) { - Icon( - imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, - contentDescription = stringResource( - if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint - ), - ) - } + val iconSize = 40.dp + // Fixed slots: emote + overflow + send (+ whisper if present) + val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 + val availableForActions = maxWidth - iconSize * fixedSlots + val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) + visibleActions = effectiveActions.take(maxVisibleActions) - Spacer(modifier = Modifier.weight(1f)) - - // Overflow Button (leading the end-aligned group) - if (showQuickActions) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + // Emote/Keyboard Button (start-aligned, always visible) IconButton( - onClick = { quickActionsExpanded = !quickActionsExpanded }, + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() + } + onEmoteClick() + }, + enabled = enabled, modifier = Modifier.size(iconSize) ) { Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant + imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, + contentDescription = stringResource( + if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint + ), ) } - } - // Configurable action icons (only those that fit) - for (action in visibleActions) { - InputActionButton( - action = action, - enabled = enabled, - isStreamActive = isStreamActive, - isFullscreen = isFullscreen, - onSearchClick = onSearchClick, - onLastMessageClick = onLastMessageClick, - onToggleStream = onToggleStream, - onChangeRoomState = onChangeRoomState, - onToggleFullscreen = onToggleFullscreen, - onToggleInput = onToggleInput, - modifier = Modifier.size(iconSize), - ) - } + Spacer(modifier = Modifier.weight(1f)) - // New Whisper Button (only on whisper tab) - if (onNewWhisper != null) { - IconButton( - onClick = onNewWhisper, - modifier = Modifier.size(iconSize) - ) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.whisper_new), + // End-aligned group: overflow + actions + whisper + send + val endAlignedContent: @Composable () -> Unit = { + // Overflow Button (leading the end-aligned group) + if (showQuickActions) { + val overflowButton: @Composable () -> Unit = { + IconButton( + onClick = { + if (overflowMenuTooltipState != null) { + onTourAdvance?.invoke() + } else { + userExpandedMenu = !quickActionsExpanded + } + }, + modifier = Modifier.size(iconSize) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (overflowMenuTooltipState != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + TourTooltip( + text = stringResource(R.string.tour_overflow_menu), + onAction = { onTourAdvance?.invoke() }, + onSkip = { onTourSkip?.invoke() }, + ) + }, + state = overflowMenuTooltipState, + hasAction = true, + ) { + overflowButton() + } + } else { + overflowButton() + } + } + + // Configurable action icons (only those that fit) + for (action in visibleActions) { + InputActionButton( + action = action, + enabled = enabled, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onChangeRoomState = onChangeRoomState, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + modifier = Modifier.size(iconSize), + ) + } + + // New Whisper Button (only on whisper tab) + if (onNewWhisper != null) { + IconButton( + onClick = onNewWhisper, + modifier = Modifier.size(iconSize) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.whisper_new), + ) + } + } + + // Send Button (Right) + SendButton( + enabled = canSend, + onSend = onSend, ) } - } - // Send Button (Right) - SendButton( - enabled = canSend, - onSend = onSend, - ) + if (inputActionsTooltipState != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + TourTooltip( + text = stringResource(R.string.tour_input_actions), + onAction = { onTourAdvance?.invoke() }, + onSkip = { onTourSkip?.invoke() }, + ) + }, + state = inputActionsTooltipState, + onDismissRequest = {}, + focusable = true, + hasAction = true, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + endAlignedContent() + } + } + } else { + endAlignedContent() + } } } + } + + actionsRowContent() } } @@ -432,7 +510,7 @@ fun ChatInputLayout( Popup( popupPositionProvider = positionProvider, - onDismissRequest = { quickActionsExpanded = false }, + onDismissRequest = { if (!forceOverflowOpen) userExpandedMenu = false }, properties = PopupProperties(focusable = true), ) { AnimatedVisibility( @@ -473,7 +551,7 @@ fun ChatInputLayout( InputAction.Fullscreen -> onToggleFullscreen() InputAction.HideInput -> onToggleInput() } - quickActionsExpanded = false + userExpandedMenu = false }, leadingIcon = { Icon( @@ -488,19 +566,45 @@ fun ChatInputLayout( HorizontalDivider() // Configure actions item - DropdownMenuItem( - text = { Text(stringResource(R.string.input_action_configure)) }, - onClick = { - quickActionsExpanded = false - showConfigSheet = true - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null - ) + val configureItem: @Composable () -> Unit = { + DropdownMenuItem( + text = { Text(stringResource(R.string.input_action_configure)) }, + onClick = { + if (configureActionsTooltipState != null) { + onTourAdvance?.invoke() + } else { + userExpandedMenu = false + showConfigSheet = true + } + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null + ) + } + ) + } + if (configureActionsTooltipState != null) { + TooltipBox( + positionProvider = rememberStartAlignedTooltipPositionProvider(), + tooltip = { + EndCaretTourTooltip( + text = stringResource(R.string.tour_configure_actions), + onAction = { onTourAdvance?.invoke() }, + onSkip = { onTourSkip?.invoke() }, + ) + }, + state = configureActionsTooltipState, + onDismissRequest = {}, + focusable = true, + hasAction = true, + ) { + configureItem() } - ) + } else { + configureItem() + } } } } @@ -508,6 +612,27 @@ fun ChatInputLayout( } } + Box(modifier = modifier.fillMaxWidth()) { + if (swipeGestureTooltipState != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + TourTooltip( + text = stringResource(R.string.tour_swipe_gesture), + onAction = { onTourAdvance?.invoke() }, + onSkip = { onTourSkip?.invoke() }, + ) + }, + state = swipeGestureTooltipState, + hasAction = true, + ) { + inputContent() + } + } else { + inputContent() + } + } + if (showConfigSheet) { InputActionConfigSheet( inputActions = inputActions, @@ -790,3 +915,121 @@ private fun InputActionButton( ) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TooltipScope.TourTooltip( + text: String, + onAction: () -> Unit, + onSkip: () -> Unit, + isLast: Boolean = false, +) { + RichTooltip( + caretShape = TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)), + 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) + } +} + +/** + * 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.surfaceContainerHigh + 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.onSurfaceVariant, + ) + 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) + } + } +} + +/** + * Positions the tooltip to the start (left in LTR) of the anchor, vertically centered. + * Falls back to above-positioning if there's not enough horizontal space. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private 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) { + // Fits to the start — vertically center on anchor + val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 + IntOffset( + startX, + y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)), + ) + } else { + // Not enough space — fall back to above, horizontally end-aligned with anchor + 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/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt index 1fdf908f4..d49e491ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt @@ -2,16 +2,16 @@ package com.flxrs.dankchat.main.compose 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.safeDrawingPadding +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.AssistChip +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 @@ -19,57 +19,63 @@ 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 -@OptIn(ExperimentalLayoutApi::class) @Composable fun EmptyStateContent( isLoggedIn: Boolean, onAddChannel: () -> Unit, onLogin: () -> Unit, - onToggleAppBar: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Surface(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() - .safeDrawingPadding(), + .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + 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), // You might need to add this string or use a literal/different string - style = MaterialTheme.typography.headlineMedium + text = stringResource(R.string.no_channels_added_body), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Shortcut chips - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) - ) { - AssistChip( - onClick = onAddChannel, - label = { Text(stringResource(R.string.add_channel)) }, - leadingIcon = { Icon(Icons.Default.Add, null) } + Spacer(modifier = Modifier.height(32.dp)) + + Button(onClick = onAddChannel) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), ) - - if (!isLoggedIn) { - AssistChip( - onClick = onLogin, - label = { Text(stringResource(R.string.login)) }, - leadingIcon = { Icon(Icons.AutoMirrored.Filled.Login, null) } + 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)) } - - AssistChip( - onClick = onToggleAppBar, - label = { Text(stringResource(R.string.toggle_app_bar)) } - ) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index dc5157a75..ff2d6d198 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable 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 @@ -39,12 +40,19 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.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.getValue @@ -52,6 +60,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.Modifier @@ -74,6 +83,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first sealed interface ToolbarAction { @@ -100,7 +110,7 @@ sealed interface ToolbarAction { data object MessageHistory : ToolbarAction } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun FloatingToolbar( tabState: ChannelTabUiState, @@ -115,10 +125,12 @@ fun FloatingToolbar( onAction: (ToolbarAction) -> Unit, endAligned: Boolean = false, showTabs: Boolean = true, + addChannelTooltipState: TooltipState? = null, + onAddChannelTooltipDismissed: () -> Unit = {}, + onSkipTour: () -> Unit = {}, streamToolbarAlpha: Float = 1f, modifier: Modifier = Modifier, ) { - if (tabState.tabs.isEmpty()) return val density = LocalDensity.current var isTabsExpanded by remember { mutableStateOf(false) } @@ -253,9 +265,14 @@ fun FloatingToolbar( }, 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, + visible = showTabs && tabState.tabs.isNotEmpty(), modifier = Modifier.weight(1f, fill = endAligned), enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), @@ -378,22 +395,70 @@ fun FloatingToolbar( color = MaterialTheme.colorScheme.surfaceContainer, ) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { onAction(ToolbarAction.AddChannel) }) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_channel) - ) + // 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)) } - IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.mentions_title), - tint = if (totalMentionCount > 0) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - } - ) + 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) + onAddChannelTooltipDismissed() + } + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + RichTooltip( + caretShape = TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)), + action = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed(); onSkipTour() }) { + Text(stringResource(R.string.tour_skip)) + } + TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed() }) { + 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 = Icons.Default.Notifications, + contentDescription = stringResource(R.string.mentions_title), + tint = if (totalMentionCount > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + ) + } } IconButton(onClick = { overflowInitialMenu = AppBarMenu.Main @@ -406,6 +471,7 @@ fun FloatingToolbar( } } } + AnimatedVisibility( visible = showOverflowMenu, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 25165a095..cd68d1242 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.rememberTooltipState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically @@ -112,11 +113,17 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.main.compose.sheets.EmoteMenu +import com.flxrs.dankchat.onboarding.OnboardingDataStore 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 com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore +import com.flxrs.dankchat.tour.FeatureTourController +import com.flxrs.dankchat.tour.PostOnboardingStep +import com.flxrs.dankchat.tour.TourStep +import com.flxrs.dankchat.tour.rememberFeatureTourController +import com.flxrs.dankchat.tour.rememberPostOnboardingCoordinator import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding import kotlinx.coroutines.launch @@ -165,7 +172,12 @@ fun MainScreen( val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() val developerSettingsDataStore: DeveloperSettingsDataStore = koinInject() val preferenceStore: DankChatPreferenceStore = koinInject() + val onboardingDataStore: OnboardingDataStore = koinInject() val mainEventBus: MainEventBus = koinInject() + val tourController = rememberFeatureTourController(onboardingDataStore) + tourController.onHideInput = { mainScreenViewModel.setGestureInputHidden(true) } + tourController.onRestoreInput = { mainScreenViewModel.setGestureInputHidden(false) } + val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val scrollTargets = remember { mutableStateMapOf() } @@ -335,6 +347,34 @@ fun MainScreen( val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel + // Post-onboarding flow: toolbar hint → feature tour + val coordinator = rememberPostOnboardingCoordinator(onboardingDataStore) + tourController.onComplete = coordinator::onTourCompleted + val postOnboardingStep = coordinator.step + val toolbarAddChannelTooltipState = rememberTooltipState(isPersistent = true) + val channelsReady = !tabState.loading + val channelsEmpty = tabState.tabs.isEmpty() && channelsReady + + // Notify coordinator when channel state changes + LaunchedEffect(channelsReady, channelsEmpty) { + coordinator.onChannelsChanged(empty = channelsEmpty, ready = channelsReady) + } + + // Drive tooltip dismissals and tour start from the typed step. + // Tooltip .show() calls live in FloatingToolbar. + LaunchedEffect(postOnboardingStep) { + when (postOnboardingStep) { + PostOnboardingStep.FeatureTour -> { + toolbarAddChannelTooltipState.dismiss() + tourController.start() + } + PostOnboardingStep.Complete, PostOnboardingStep.Idle -> { + toolbarAddChannelTooltipState.dismiss() + } + PostOnboardingStep.ToolbarPlusHint -> Unit + } + } + MainScreenDialogs( dialogViewModel = dialogViewModel, activeChannel = activeChannel, @@ -432,7 +472,6 @@ fun MainScreen( } val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() - val showAppBar by mainScreenViewModel.showAppBar.collectAsStateWithLifecycle() val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() val inputActions by appearanceSettingsDataStore.inputActions.collectAsStateWithLifecycle( initialValue = appearanceSettingsDataStore.current().inputActions @@ -440,7 +479,21 @@ fun MainScreen( val gestureInputHidden by mainScreenViewModel.gestureInputHidden.collectAsStateWithLifecycle() val gestureToolbarHidden by mainScreenViewModel.gestureToolbarHidden.collectAsStateWithLifecycle() val effectiveShowInput = showInputState && !gestureInputHidden - val effectiveShowAppBar = showAppBar && !gestureToolbarHidden + val effectiveShowAppBar = !gestureToolbarHidden + + // Auto-advance tour when input is hidden during the SwipeGesture step (e.g. by actual swipe) + LaunchedEffect(gestureInputHidden, tourController.currentStep) { + if (gestureInputHidden && tourController.currentStep == TourStep.SwipeGesture) { + tourController.advance() + } + } + + // Keep toolbar visible during tour + LaunchedEffect(tourController.isActive, gestureToolbarHidden) { + if (tourController.isActive && gestureToolbarHidden) { + mainScreenViewModel.setGestureToolbarHidden(false) + } + } val toolbarTracker = remember { ScrollDirectionTracker( @@ -597,6 +650,13 @@ fun MainScreen( }, onInputHeightChanged = { inputHeightPx = it }, instantHide = isHistorySheet, + inputActionsTooltipState = if (tourController.currentStep == TourStep.InputActions) tourController.inputActionsTooltipState else null, + overflowMenuTooltipState = if (tourController.currentStep == TourStep.OverflowMenu) tourController.overflowMenuTooltipState else null, + configureActionsTooltipState = if (tourController.currentStep == TourStep.ConfigureActions) tourController.configureActionsTooltipState else null, + swipeGestureTooltipState = if (tourController.currentStep == TourStep.SwipeGesture) tourController.swipeGestureTooltipState else null, + forceOverflowOpen = tourController.forceOverflowOpen, + onTourAdvance = tourController::advance, + onTourSkip = tourController::skipTour, ) } @@ -611,7 +671,10 @@ fun MainScreen( channelTabViewModel.selectTab(action.index) scope.launch { composePagerState.scrollToPage(action.index) } } - ToolbarAction.AddChannel -> dialogViewModel.showAddChannel() + ToolbarAction.AddChannel -> { + coordinator.onAddedChannelFromToolbar() + dialogViewModel.showAddChannel() + } ToolbarAction.OpenMentions -> sheetNavigationViewModel.openMentions() ToolbarAction.Login -> onLogin() ToolbarAction.Relogin -> onRelogin() @@ -660,6 +723,9 @@ fun MainScreen( onAction = handleToolbarAction, endAligned = endAligned, showTabs = showTabs, + addChannelTooltipState = if (postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) toolbarAddChannelTooltipState else null, + onAddChannelTooltipDismissed = coordinator::onToolbarHintDismissed, + onSkipTour = tourController::skipTour, streamToolbarAlpha = if (hasVisibleStream || isKeyboardClosingWithStream || wasKeyboardClosingWithStream) streamToolbarAlpha.value else 1f, modifier = toolbarModifier, ) @@ -717,8 +783,7 @@ fun MainScreen( isLoggedIn = isLoggedIn, onAddChannel = dialogViewModel::showAddChannel, onLogin = onLogin, - onToggleAppBar = mainScreenViewModel::toggleAppBar, - modifier = Modifier.padding(paddingValues) + modifier = Modifier.padding(paddingValues), ) } else { Column( @@ -783,6 +848,9 @@ fun MainScreen( onScrollDirectionChanged = { }, scrollToMessageId = scrollTargets[channel], onScrollToMessageHandled = { scrollTargets.remove(channel) }, + recoveryFabTooltipState = if (tourController.currentStep == TourStep.RecoveryFab) tourController.recoveryFabTooltipState else null, + onTourAdvance = tourController::advance, + onTourSkip = tourController::skipTour, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index 39e6b744f..dc4eaa09a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -43,9 +43,6 @@ class MainScreenViewModel( private val _isFullscreen = MutableStateFlow(false) val isFullscreen: StateFlow = _isFullscreen.asStateFlow() - private val _showAppBar = MutableStateFlow(true) - val showAppBar: StateFlow = _showAppBar.asStateFlow() - private val _gestureInputHidden = MutableStateFlow(false) val gestureInputHidden: StateFlow = _gestureInputHidden.asStateFlow() @@ -79,10 +76,6 @@ class MainScreenViewModel( _isFullscreen.update { !it } } - fun toggleAppBar() { - _showAppBar.update { !it } - } - fun retryDataLoading(dataFailures: Set, chatFailures: Set) { channelDataCoordinator.retryDataLoading(dataFailures, chatFailures) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt new file mode 100644 index 000000000..504435c4f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt @@ -0,0 +1,63 @@ +package com.flxrs.dankchat.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 { + return !currentData.hasCompletedOnboarding && dankChatPreferenceStore.hasMessageHistoryAcknowledged + } + + override suspend fun migrate(currentData: OnboardingSettings): OnboardingSettings { + return currentData.copy( + hasCompletedOnboarding = true, + hasShownAddChannelHint = true, + hasShownToolbarHint = true, + ) + } + + override suspend fun cleanUp() = Unit + } + + private val dataStore = createDataStore( + fileName = "onboarding", + context = context, + defaultValue = OnboardingSettings(), + serializer = OnboardingSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(existingUserMigration), + ) + + val settings = dataStore.safeData(OnboardingSettings()) + val currentSettings = settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() } + ) + + fun current() = currentSettings.value + + suspend fun update(transform: suspend (OnboardingSettings) -> OnboardingSettings) { + runCatching { dataStore.updateData(transform) } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt new file mode 100644 index 000000000..8c2e4d0fc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt @@ -0,0 +1,453 @@ +package com.flxrs.dankchat.onboarding + +import android.Manifest +import android.content.Context +import android.content.Intent +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.Box +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.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.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 com.flxrs.dankchat.utils.compose.buildLinkAnnotation +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import android.content.pm.PackageManager +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.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, + loginSuccess: Boolean, + 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) + } + + LaunchedEffect(loginSuccess) { + if (loginSuccess) { + viewModel.onLoginCompleted() + // Auto-advance past login page + if (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( + onGetStarted = { 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 = { + scope.launch { + viewModel.completeOnboarding() + onComplete() + } + }, + ) + } + } + } + } +} + +@Composable +private fun OnboardingPage( + title: String, + modifier: Modifier = Modifier, + icon: @Composable () -> Unit, + body: @Composable () -> Unit, + 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( + onGetStarted: () -> 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.primary, + ) + }, + title = stringResource(R.string.onboarding_welcome_title), + body = { OnboardingBody(stringResource(R.string.onboarding_welcome_body)) }, + modifier = modifier, + ) { + Button(onClick = onGetStarted) { + 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)) }, + 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.bodyLarge, + 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 } + +@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/onboarding/OnboardingSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingSettings.kt new file mode 100644 index 000000000..96131725a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingSettings.kt @@ -0,0 +1,13 @@ +package com.flxrs.dankchat.onboarding + +import kotlinx.serialization.Serializable + +@Serializable +data class OnboardingSettings( + val hasCompletedOnboarding: 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/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt new file mode 100644 index 000000000..8f73f2fc2 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt @@ -0,0 +1,69 @@ +package com.flxrs.dankchat.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.android.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 dankChatPreferenceStore: DankChatPreferenceStore, + private val chatSettingsDataStore: ChatSettingsDataStore, +) : ViewModel() { + + private val _state: MutableStateFlow + val state: StateFlow + + init { + val savedPage = runBlocking { onboardingDataStore.current().onboardingPage } + val isLoggedIn = dankChatPreferenceStore.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() + } + + fun setCurrentPage(page: Int) { + _state.update { it.copy(currentPage = page) } + viewModelScope.launch { + onboardingDataStore.update { it.copy(onboardingPage = page) } + } + } + + fun onLoginCompleted() { + _state.update { it.copy(loginCompleted = true) } + } + + fun onMessageHistoryDecision(enabled: Boolean) { + _state.update { it.copy(messageHistoryDecided = true, messageHistoryEnabled = enabled) } + } + + suspend fun completeOnboarding() { + val historyEnabled = _state.value.messageHistoryEnabled + dankChatPreferenceStore.hasMessageHistoryAcknowledged = true + chatSettingsDataStore.update { it.copy(loadMessageHistory = historyEnabled) } + onboardingDataStore.update { it.copy(hasCompletedOnboarding = true, onboardingPage = 0) } + } +} 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 1e2cc65d4..2cb30879d 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,7 +10,7 @@ data class DeveloperSettings( val customRecentMessagesHost: String = RM_HOST_DEFAULT, val eventSubEnabled: Boolean = true, val eventSubDebugOutput: Boolean = false, - val useComposeChatUi: Boolean = false, // Feature toggle for Compose migration + val useComposeChatUi: Boolean = true, // Feature toggle for Compose migration ) { val isPubSubShutdown: Boolean get() = System.currentTimeMillis() > PUBSUB_SHUTDOWN_MILLIS diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt new file mode 100644 index 000000000..9dd3b29b5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt @@ -0,0 +1,166 @@ +package com.flxrs.dankchat.tour + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +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 com.flxrs.dankchat.onboarding.OnboardingDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +const val CURRENT_TOUR_VERSION = 1 + +enum class TourStep { + InputActions, + OverflowMenu, + ConfigureActions, + SwipeGesture, + RecoveryFab, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Stable +class FeatureTourController( + private val onboardingDataStore: OnboardingDataStore, + private val scope: CoroutineScope, +) { + var isActive by mutableStateOf(false) + private set + + var currentStepIndex by mutableIntStateOf(0) + private set + + val currentStep: TourStep? + get() = when { + !isActive -> null + currentStepIndex >= TourStep.entries.size -> null + else -> TourStep.entries[currentStepIndex] + } + + /** When true, ChatInputLayout should force the overflow menu open. */ + var forceOverflowOpen by mutableStateOf(false) + private set + + /** Set by MainScreen to hide input for the RecoveryFab step. */ + var onHideInput: (() -> Unit)? = null + + /** Set by MainScreen to restore input when the tour completes. */ + var onRestoreInput: (() -> Unit)? = null + + /** Called when the tour finishes (either completed or skipped). */ + var onComplete: (() -> Unit)? = null + + 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) + + fun start() { + if (isActive) return + isActive = true + 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. + currentStepIndex = when { + CURRENT_TOUR_VERSION - settings.featureTourVersion == 1 -> settings.featureTourStep.coerceIn(0, TourStep.entries.size - 1) + else -> 0 + } + applyStepSideEffects() + showCurrentTooltip() + } + + fun advance() { + val leavingStep = currentStep + // 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 (leavingStep != TourStep.ConfigureActions) { + dismissCurrentTooltip() + } + currentStepIndex++ + val nextStep = currentStep + when { + nextStep == null -> { + completeTour() + return + } + else -> { + scope.launch { onboardingDataStore.update { it.copy(featureTourStep = currentStepIndex) } } + applyStepSideEffects() + } + } + if (leavingStep == TourStep.ConfigureActions) { + // Menu close animation takes ~150ms; show the next tooltip after it finishes + scope.launch { + delay(250) + showCurrentTooltip() + } + } else { + showCurrentTooltip() + } + } + + private fun applyStepSideEffects() { + when (currentStep) { + TourStep.ConfigureActions -> forceOverflowOpen = true + TourStep.SwipeGesture -> forceOverflowOpen = false + TourStep.RecoveryFab -> onHideInput?.invoke() + else -> {} + } + } + + fun skipTour() { + dismissCurrentTooltip() + forceOverflowOpen = false + completeTour() + } + + private fun completeTour() { + isActive = false + onRestoreInput?.invoke() + onComplete?.invoke() + scope.launch { + onboardingDataStore.update { it.copy(featureTourVersion = CURRENT_TOUR_VERSION, featureTourStep = 0) } + } + } + + private fun showCurrentTooltip() { + val state = tooltipStateForStep(currentStep ?: return) + scope.launch { state.show() } + } + + private fun dismissCurrentTooltip() { + val step = currentStep ?: return + tooltipStateForStep(step).dismiss() + } + + private fun tooltipStateForStep(step: TourStep): TooltipState = when (step) { + TourStep.InputActions -> inputActionsTooltipState + TourStep.OverflowMenu -> overflowMenuTooltipState + TourStep.ConfigureActions -> configureActionsTooltipState + TourStep.SwipeGesture -> swipeGestureTooltipState + TourStep.RecoveryFab -> recoveryFabTooltipState + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun rememberFeatureTourController( + onboardingDataStore: OnboardingDataStore, +): FeatureTourController { + val scope = rememberCoroutineScope() + return remember(onboardingDataStore) { + FeatureTourController( + onboardingDataStore = onboardingDataStore, + scope = scope, + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt new file mode 100644 index 000000000..da40ca3a8 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt @@ -0,0 +1,91 @@ +package com.flxrs.dankchat.tour + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +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 com.flxrs.dankchat.onboarding.OnboardingDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@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 +} + +@Stable +class PostOnboardingCoordinator( + private val onboardingDataStore: OnboardingDataStore, + private val scope: CoroutineScope, +) { + var step by mutableStateOf(PostOnboardingStep.Idle) + private set + + private var channelsReady = false + private var isEmpty = true + private var toolbarHintDone = false + + fun onChannelsChanged(empty: Boolean, ready: Boolean) { + isEmpty = empty + channelsReady = ready + resolveStep() + } + + /** User already used the toolbar + icon, no need to show the hint. */ + fun onAddedChannelFromToolbar() { + if (toolbarHintDone) return + toolbarHintDone = true + scope.launch { onboardingDataStore.update { it.copy(hasShownToolbarHint = true) } } + } + + fun onToolbarHintDismissed() { + if (toolbarHintDone) return // idempotent for external-dismiss handler + toolbarHintDone = true + scope.launch { onboardingDataStore.update { it.copy(hasShownToolbarHint = true) } } + val settings = onboardingDataStore.current() + step = when { + settings.featureTourVersion < CURRENT_TOUR_VERSION && !isEmpty -> PostOnboardingStep.FeatureTour + else -> PostOnboardingStep.Complete + } + } + + fun onTourCompleted() { + step = PostOnboardingStep.Complete + } + + private fun resolveStep() { + if (!channelsReady || step is PostOnboardingStep.Complete) return + val settings = onboardingDataStore.current() + val toolbarDone = settings.hasShownToolbarHint || toolbarHintDone + + step = when { + !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle + isEmpty -> PostOnboardingStep.Idle + !toolbarDone -> PostOnboardingStep.ToolbarPlusHint + settings.featureTourVersion < CURRENT_TOUR_VERSION -> PostOnboardingStep.FeatureTour + else -> PostOnboardingStep.Complete + } + } +} + +@Composable +fun rememberPostOnboardingCoordinator(onboardingDataStore: OnboardingDataStore): PostOnboardingCoordinator { + val scope = rememberCoroutineScope() + return remember(onboardingDataStore) { + PostOnboardingCoordinator(onboardingDataStore, scope) + } +} 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/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 2882c94e7..42b8c68bd 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -422,4 +422,13 @@ Забаніць гэтага карыстальніка? Выдаліць гэтае паведамленне? Ачысціць чат? + Наладжвальныя дзеянні для хуткага доступу да пошуку, трансляцый і іншага + Націсніце тут для дадатковых дзеянняў і наладкі панэлі дзеянняў + Тут вы можаце наладзіць, якія дзеянні адлюстроўваюцца на панэлі дзеянняў + Правядзіце ўніз па полю ўводу, каб хутка схаваць яго + Націсніце тут, каб вярнуць поле ўводу + Далей + Зразумела + Прапусціць тур + Тут вы можаце дадаць больш каналаў diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index a686a0e63..d0b54b91d 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -311,4 +311,13 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Missatges amb emotes Filtra per nom d\'insígnia Usuari - Insígnia + Insígnia 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 + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 074943081..a505eda5a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -423,4 +423,13 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 882bfb8c7..c6e5f31c9 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -438,4 +438,13 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index f63d67aca..8971e6fa4 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -248,4 +248,13 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Messages containing emotes Filter by badge name User - Badge + 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 + diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 3cbb9571e..bd26f8a4d 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -249,4 +249,13 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Messages containing emotes Filter by badge name User - Badge + 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 + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index cab3934e0..f58648b63 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -431,4 +431,13 @@ 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index e3cc4458c..ec89a4b1e 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -437,4 +437,13 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/¿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í diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index ddc69a73d..092735bed 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -275,4 +275,13 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Emojeja sisältävät viestit Suodata merkin nimen mukaan Käyttäjä - Merkki + Merkki 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ä + diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 79f12e169..bbaf266df 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -421,4 +421,13 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 69a053e4c..df4b98e87 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -416,4 +416,13 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 00b071437..c76cc8f0f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -405,4 +405,13 @@ 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index d3f7b2483..136a72900 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -403,4 +403,13 @@ このユーザーをBANしますか? このメッセージを削除しますか? チャットをクリアしますか? + 検索、配信などへのクイックアクセスのためのカスタマイズ可能なアクション + ここをタップしてその他のアクションやアクションバーの設定ができます + アクションバーに表示するアクションをここでカスタマイズできます + 入力欄を下にスワイプすると素早く非表示にできます + ここをタップして入力欄を元に戻します + 次へ + 了解 + ツアーをスキップ + ここでチャンネルを追加できます diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 86d4941fe..a789b2e0d 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -441,4 +441,13 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 93a2d2f56..871d7f933 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -416,4 +416,13 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b74a3fcc5..fb2f5114d 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -407,4 +407,13 @@ 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 30d9d46a2..56b7a06ac 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -427,4 +427,13 @@ Забанить этого пользователя? Удалить это сообщение? Очистить чат? + Настраиваемые действия для быстрого доступа к поиску, трансляциям и другому + Нажмите здесь для дополнительных действий и настройки панели действий + Здесь можно настроить, какие действия отображаются на панели действий + Проведите вниз по полю ввода, чтобы быстро скрыть его + Нажмите здесь, чтобы вернуть поле ввода + Далее + Понятно + Пропустить тур + Здесь можно добавить больше каналов diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 86c2f48f6..b6cc204a8 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -216,4 +216,13 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Поруке са емотима Филтрирај по називу значке Корисник - Значка + Значка Прилагодљиве акције за брз приступ претрази, стримовима и другом + Додирните овде за више акција и подешавање траке акција + Овде можете прилагодити које акције се приказују на траци акција + Превуците надоле по пољу за унос да бисте га брзо сакрили + Додирните овде да вратите поље за унос + Даље + Разумем + Прескочи обилазак + Овде можете додати више канала + diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index fc0920fcd..62711c831 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -437,4 +437,13 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 2d5d707c6..db2a5dd13 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -424,4 +424,13 @@ Забанити цього користувача? Видалити це повідомлення? Очистити чат? + Налаштовувані дії для швидкого доступу до пошуку, трансляцій та іншого + Натисніть тут для додаткових дій та налаштування панелі дій + Тут ви можете налаштувати, які дії відображаються на панелі дій + Проведіть вниз по полю введення, щоб швидко сховати його + Натисніть тут, щоб повернути поле введення + Далі + Зрозуміло + Пропустити тур + Тут ви можете додати більше каналів diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d24b3c864..44892afca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Remove channel Channel blocked No channels added + Add a channel to start chatting Confirm logout Are you sure you want to logout? Log out? @@ -521,4 +522,42 @@ 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. + Login with Twitch + Login successful + Skip + Continue + Message History + DankChat loads historical messages from a third-party service on startup.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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 + Battery Optimization + DankChat runs a background service for notifications. Some devices aggressively kill background apps, which can cause missed notifications.\n\nExcluding DankChat from battery optimization helps keep it running reliably. + Open Settings + Battery optimization disabled + You\'re all set! + Start by adding a Twitch channel to chat in. + Start + + + 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 From a3ab649b4708deeb57ce4030dc3b8f912f533e17 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 21:49:42 +0100 Subject: [PATCH 038/349] refactor(compose): Architecture cleanup and bug fixes --- .../main/compose/ChannelPagerViewModel.kt | 1 + .../dankchat/main/compose/ChatBottomBar.kt | 17 +- .../dankchat/main/compose/ChatInputLayout.kt | 321 ++++++++++-------- .../flxrs/dankchat/main/compose/MainScreen.kt | 156 +++++---- .../main/compose/MainScreenEventHandler.kt | 12 +- .../main/compose/MainScreenViewModel.kt | 39 ++- .../main/compose/SheetNavigationViewModel.kt | 7 +- .../onboarding/OnboardingDataStore.kt | 8 + .../dankchat/tour/FeatureTourController.kt | 12 +- .../tour/PostOnboardingCoordinator.kt | 11 +- .../utils/compose/RoundedCornerPadding.kt | 2 + 11 files changed, 343 insertions(+), 243 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt index 2fb1f06e3..3db3d7234 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -56,6 +56,7 @@ class ChannelPagerViewModel( } } +@Immutable data class JumpTarget(val channelIndex: Int, val channel: UserName, val messageId: String) @Immutable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index f461259f3..159d8de63 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TooltipState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned @@ -53,13 +52,7 @@ fun ChatBottomBar( onInputActionsChanged: (List) -> Unit, onInputHeightChanged: (Int) -> Unit, instantHide: Boolean = false, - inputActionsTooltipState: TooltipState? = null, - overflowMenuTooltipState: TooltipState? = null, - configureActionsTooltipState: TooltipState? = null, - swipeGestureTooltipState: TooltipState? = null, - forceOverflowOpen: Boolean = false, - onTourAdvance: (() -> Unit)? = null, - onTourSkip: (() -> Unit)? = null, + tourState: TourOverlayState = TourOverlayState(), ) { Column(modifier = Modifier.fillMaxWidth()) { AnimatedVisibility( @@ -101,13 +94,7 @@ fun ChatBottomBar( onSearchClick = onSearchClick, onNewWhisper = onNewWhisper, showQuickActions = !isSheetOpen, - inputActionsTooltipState = inputActionsTooltipState, - overflowMenuTooltipState = overflowMenuTooltipState, - configureActionsTooltipState = configureActionsTooltipState, - swipeGestureTooltipState = swipeGestureTooltipState, - forceOverflowOpen = forceOverflowOpen, - onTourAdvance = onTourAdvance, - onTourSkip = onTourSkip, + tourState = tourState, modifier = Modifier.onGloballyPositioned { coordinates -> onInputHeightChanged(coordinates.size.height) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 7583ef2f4..1b7c574b8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Immutable import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -107,6 +108,18 @@ import sh.calvin.reorderable.ReorderableColumn private const val MAX_INPUT_ACTIONS = 4 +@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 onAdvance: (() -> Unit)? = null, + val onSkip: (() -> Unit)? = null, +) + @Composable fun ChatInputLayout( textFieldState: TextFieldState, @@ -139,13 +152,7 @@ fun ChatInputLayout( onSearchClick: () -> Unit = {}, onNewWhisper: (() -> Unit)? = null, showQuickActions: Boolean = true, - inputActionsTooltipState: TooltipState? = null, - overflowMenuTooltipState: TooltipState? = null, - configureActionsTooltipState: TooltipState? = null, - swipeGestureTooltipState: TooltipState? = null, - forceOverflowOpen: Boolean = false, - onTourAdvance: (() -> Unit)? = null, - onTourSkip: (() -> Unit)? = null, + tourState: TourOverlayState = TourOverlayState(), modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } @@ -187,7 +194,7 @@ fun ChatInputLayout( var visibleActions by remember { mutableStateOf(effectiveActions) } var userExpandedMenu by remember { mutableStateOf(false) } - val quickActionsExpanded = userExpandedMenu || forceOverflowOpen + val quickActionsExpanded = userExpandedMenu || tourState.forceOverflowOpen var showConfigSheet by remember { mutableStateOf(false) } val topEndRadius by animateDpAsState( targetValue = if (quickActionsExpanded) 0.dp else 24.dp, @@ -387,8 +394,8 @@ fun ChatInputLayout( val overflowButton: @Composable () -> Unit = { IconButton( onClick = { - if (overflowMenuTooltipState != null) { - onTourAdvance?.invoke() + if (tourState.overflowMenuTooltipState != null) { + tourState.onAdvance?.invoke() } else { userExpandedMenu = !quickActionsExpanded } @@ -402,17 +409,17 @@ fun ChatInputLayout( ) } } - if (overflowMenuTooltipState != null) { + if (tourState.overflowMenuTooltipState != null) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { TourTooltip( text = stringResource(R.string.tour_overflow_menu), - onAction = { onTourAdvance?.invoke() }, - onSkip = { onTourSkip?.invoke() }, + onAction = { tourState.onAdvance?.invoke() }, + onSkip = { tourState.onSkip?.invoke() }, ) }, - state = overflowMenuTooltipState, + state = tourState.overflowMenuTooltipState, hasAction = true, ) { overflowButton() @@ -459,17 +466,17 @@ fun ChatInputLayout( ) } - if (inputActionsTooltipState != null) { + if (tourState.inputActionsTooltipState != null) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { TourTooltip( text = stringResource(R.string.tour_input_actions), - onAction = { onTourAdvance?.invoke() }, - onSkip = { onTourSkip?.invoke() }, + onAction = { tourState.onAdvance?.invoke() }, + onSkip = { tourState.onSkip?.invoke() }, ) }, - state = inputActionsTooltipState, + state = tourState.inputActionsTooltipState, onDismissRequest = {}, focusable = true, hasAction = true, @@ -489,141 +496,46 @@ fun ChatInputLayout( } } - // Quick actions menu — Popup with custom positioning and slide animation - val menuVisibleState = remember { MutableTransitionState(false) } - menuVisibleState.targetState = quickActionsExpanded - - if (menuVisibleState.currentState || menuVisibleState.targetState) { - val positionProvider = remember { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset = IntOffset( - x = anchorBounds.right - popupContentSize.width, - y = anchorBounds.top - popupContentSize.height - ) + QuickActionsOverflowMenu( + expanded = quickActionsExpanded, + surfaceColor = surfaceColor, + visibleActions = visibleActions, + isStreamActive = isStreamActive, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = isModerator, + tourState = tourState, + onDismiss = { if (!tourState.forceOverflowOpen) userExpandedMenu = false }, + onActionClick = { action -> + when (action) { + InputAction.Search -> onSearchClick() + InputAction.LastMessage -> onLastMessageClick() + InputAction.Stream -> onToggleStream() + InputAction.RoomState -> onChangeRoomState() + InputAction.Fullscreen -> onToggleFullscreen() + InputAction.HideInput -> onToggleInput() } - } - - Popup( - popupPositionProvider = positionProvider, - onDismissRequest = { if (!forceOverflowOpen) userExpandedMenu = false }, - properties = PopupProperties(focusable = true), - ) { - AnimatedVisibility( - visibleState = menuVisibleState, - enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = tween(durationMillis = 150) - ) + fadeIn(animationSpec = tween(durationMillis = 100)), - exit = shrinkVertically( - shrinkTowards = Alignment.Bottom, - animationSpec = tween(durationMillis = 120) - ) + fadeOut(animationSpec = tween(durationMillis = 80)), - ) { - Surface( - shape = RoundedCornerShape(topStart = 12.dp), - color = surfaceColor, - ) { - Column(modifier = Modifier.width(IntrinsicSize.Max)) { - // Overflow items: actions NOT visible in the action bar - 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) { - DropdownMenuItem( - text = { Text(stringResource(overflowItem.labelRes)) }, - onClick = { - when (action) { - InputAction.Search -> onSearchClick() - InputAction.LastMessage -> onLastMessageClick() - InputAction.Stream -> onToggleStream() - InputAction.RoomState -> onChangeRoomState() - InputAction.Fullscreen -> onToggleFullscreen() - InputAction.HideInput -> onToggleInput() - } - userExpandedMenu = false - }, - leadingIcon = { - Icon( - imageVector = overflowItem.icon, - contentDescription = null - ) - } - ) - } - } - - HorizontalDivider() - - // Configure actions item - val configureItem: @Composable () -> Unit = { - DropdownMenuItem( - text = { Text(stringResource(R.string.input_action_configure)) }, - onClick = { - if (configureActionsTooltipState != null) { - onTourAdvance?.invoke() - } else { - userExpandedMenu = false - showConfigSheet = true - } - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null - ) - } - ) - } - if (configureActionsTooltipState != null) { - TooltipBox( - positionProvider = rememberStartAlignedTooltipPositionProvider(), - tooltip = { - EndCaretTourTooltip( - text = stringResource(R.string.tour_configure_actions), - onAction = { onTourAdvance?.invoke() }, - onSkip = { onTourSkip?.invoke() }, - ) - }, - state = configureActionsTooltipState, - onDismissRequest = {}, - focusable = true, - hasAction = true, - ) { - configureItem() - } - } else { - configureItem() - } - } - } - } - } - } + userExpandedMenu = false + }, + onConfigureClick = { + userExpandedMenu = false + showConfigSheet = true + }, + ) } Box(modifier = modifier.fillMaxWidth()) { - if (swipeGestureTooltipState != null) { + if (tourState.swipeGestureTooltipState != null) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { TourTooltip( text = stringResource(R.string.tour_swipe_gesture), - onAction = { onTourAdvance?.invoke() }, - onSkip = { onTourSkip?.invoke() }, + onAction = { tourState.onAdvance?.invoke() }, + onSkip = { tourState.onSkip?.invoke() }, ) }, - state = swipeGestureTooltipState, + state = tourState.swipeGestureTooltipState, hasAction = true, ) { inputContent() @@ -642,6 +554,131 @@ fun ChatInputLayout( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun QuickActionsOverflowMenu( + expanded: Boolean, + surfaceColor: Color, + visibleActions: List, + isStreamActive: Boolean, + hasStreamData: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + tourState: TourOverlayState, + onDismiss: () -> Unit, + onActionClick: (InputAction) -> Unit, + onConfigureClick: () -> Unit, +) { + val menuVisibleState = remember { MutableTransitionState(false) } + menuVisibleState.targetState = expanded + + if (!menuVisibleState.currentState && !menuVisibleState.targetState) return + + val positionProvider = remember { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset = IntOffset( + x = anchorBounds.right - popupContentSize.width, + y = anchorBounds.top - popupContentSize.height + ) + } + } + + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + AnimatedVisibility( + visibleState = menuVisibleState, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 150) + ) + fadeIn(animationSpec = tween(durationMillis = 100)), + exit = shrinkVertically( + shrinkTowards = Alignment.Bottom, + animationSpec = tween(durationMillis = 120) + ) + fadeOut(animationSpec = tween(durationMillis = 80)), + ) { + Surface( + shape = RoundedCornerShape(topStart = 12.dp), + color = surfaceColor, + ) { + 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) { + DropdownMenuItem( + text = { Text(stringResource(overflowItem.labelRes)) }, + onClick = { onActionClick(action) }, + leadingIcon = { + Icon( + imageVector = overflowItem.icon, + contentDescription = null + ) + } + ) + } + } + + HorizontalDivider() + + val configureItem: @Composable () -> Unit = { + DropdownMenuItem( + text = { Text(stringResource(R.string.input_action_configure)) }, + onClick = { + if (tourState.configureActionsTooltipState != null) { + tourState.onAdvance?.invoke() + } else { + onConfigureClick() + } + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null + ) + } + ) + } + if (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, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index cd68d1242..3f839cbc0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.rememberTooltipState import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -56,6 +57,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -190,40 +192,23 @@ fun MainScreen( val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = developerSettingsDataStore.current()) val isRepeatedSendEnabled = developerSettings.repeatedSending - var keyboardHeightPx by remember(isLandscape) { - val persisted = if (isLandscape) preferenceStore.keyboardHeightLandscape else preferenceStore.keyboardHeightPortrait - mutableIntStateOf(persisted) - } - 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 - LaunchedEffect(isLandscape, density) { - snapshotFlow { - (imeTarget.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) - } - .debounce(300) - .collect { height -> - val minHeight = with(density) { 100.dp.toPx() } - if (height > minHeight) { - keyboardHeightPx = height - if (isLandscape) { - preferenceStore.keyboardHeightLandscape = height - } else { - preferenceStore.keyboardHeightPortrait = height - } - } - } - } + // 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 @@ -244,37 +229,8 @@ fun MainScreen( // Stream state val currentStream by streamViewModel.currentStreamedChannel.collectAsStateWithLifecycle() val hasStreamData by streamViewModel.hasStreamData.collectAsStateWithLifecycle() - var streamHeightDp by remember { mutableStateOf(0.dp) } - val streamToolbarAlpha = remember { Animatable(0f) } - val hasVisibleStream = currentStream != null && streamHeightDp > 0.dp - var prevHasVisibleStream by remember { mutableStateOf(false) } - // Detect keyboard starting to close while stream exists val imeTargetBottom = with(density) { WindowInsets.imeAnimationTarget.getBottom(density) } - val isKeyboardClosingWithStream = currentStream != null && isKeyboardVisible && imeTargetBottom == 0 - // Bridge the gap between keyboard fully closed and stream measured - var wasKeyboardClosingWithStream by remember { mutableStateOf(false) } - if (isKeyboardClosingWithStream) wasKeyboardClosingWithStream = true - if (hasVisibleStream) wasKeyboardClosingWithStream = false - // Fade on stream visibility changes (keyboard show/hide in stacked layout) - LaunchedEffect(hasVisibleStream, isKeyboardClosingWithStream) { - when { - isKeyboardClosingWithStream -> { - streamToolbarAlpha.animateTo(0f, tween(durationMillis = 150)) - } - hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { - prevHasVisibleStream = hasVisibleStream - streamToolbarAlpha.snapTo(0f) - streamToolbarAlpha.animateTo(1f, tween(durationMillis = 350)) - } - !hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { - prevHasVisibleStream = hasVisibleStream - streamToolbarAlpha.snapTo(0f) - } - } - } - LaunchedEffect(currentStream) { - if (currentStream == null) streamHeightDp = 0.dp - } + val streamState = rememberStreamToolbarState(currentStream, isKeyboardVisible, imeTargetBottom) // PiP state — observe via lifecycle since onPause fires when entering PiP val activity = context as? Activity @@ -650,13 +606,15 @@ fun MainScreen( }, onInputHeightChanged = { inputHeightPx = it }, instantHide = isHistorySheet, - inputActionsTooltipState = if (tourController.currentStep == TourStep.InputActions) tourController.inputActionsTooltipState else null, - overflowMenuTooltipState = if (tourController.currentStep == TourStep.OverflowMenu) tourController.overflowMenuTooltipState else null, - configureActionsTooltipState = if (tourController.currentStep == TourStep.ConfigureActions) tourController.configureActionsTooltipState else null, - swipeGestureTooltipState = if (tourController.currentStep == TourStep.SwipeGesture) tourController.swipeGestureTooltipState else null, - forceOverflowOpen = tourController.forceOverflowOpen, - onTourAdvance = tourController::advance, - onTourSkip = tourController::skipTour, + tourState = TourOverlayState( + inputActionsTooltipState = if (tourController.currentStep == TourStep.InputActions) tourController.inputActionsTooltipState else null, + overflowMenuTooltipState = if (tourController.currentStep == TourStep.OverflowMenu) tourController.overflowMenuTooltipState else null, + configureActionsTooltipState = if (tourController.currentStep == TourStep.ConfigureActions) tourController.configureActionsTooltipState else null, + swipeGestureTooltipState = if (tourController.currentStep == TourStep.SwipeGesture) tourController.swipeGestureTooltipState else null, + forceOverflowOpen = tourController.forceOverflowOpen, + onAdvance = tourController::advance, + onSkip = tourController::skipTour, + ), ) } @@ -718,7 +676,7 @@ fun MainScreen( isLoggedIn = isLoggedIn, currentStream = currentStream, hasStreamData = hasStreamData, - streamHeightDp = streamHeightDp, + streamHeightDp = streamState.heightDp, totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, onAction = handleToolbarAction, endAligned = endAligned, @@ -726,7 +684,7 @@ fun MainScreen( addChannelTooltipState = if (postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) toolbarAddChannelTooltipState else null, onAddChannelTooltipDismissed = coordinator::onToolbarHintDismissed, onSkipTour = tourController::skipTour, - streamToolbarAlpha = if (hasVisibleStream || isKeyboardClosingWithStream || wasKeyboardClosingWithStream) streamToolbarAlpha.value else 1f, + streamToolbarAlpha = streamState.effectiveAlpha, modifier = toolbarModifier, ) } @@ -1059,7 +1017,7 @@ fun MainScreen( ) }, ) { paddingValues -> - val chatTopPadding = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamHeightDp * streamToolbarAlpha.value) + val chatTopPadding = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamState.heightDp * streamState.alpha.value) scaffoldContent(paddingValues, chatTopPadding) } } // end !isInPipMode @@ -1092,15 +1050,15 @@ fun MainScreen( Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .graphicsLayer { alpha = streamToolbarAlpha.value } + .graphicsLayer { alpha = streamState.alpha.value } .onGloballyPositioned { coordinates -> - streamHeightDp = with(density) { coordinates.size.height.toDp() } + streamState.heightDp = with(density) { coordinates.size.height.toDp() } } } ) } if (!showStream) { - streamHeightDp = 0.dp + streamState.heightDp = 0.dp } } @@ -1132,7 +1090,7 @@ fun MainScreen( .align(Alignment.TopCenter) .fillMaxWidth() .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .graphicsLayer { alpha = streamToolbarAlpha.value } + .graphicsLayer { alpha = streamState.alpha.value } .background(MaterialTheme.colorScheme.surface) ) } @@ -1175,3 +1133,65 @@ fun MainScreen( } } +@Stable +private class StreamToolbarState( + val alpha: Animatable, +) { + var heightDp by mutableStateOf(0.dp) + private var prevHasVisibleStream by mutableStateOf(false) + private var isKeyboardClosingWithStream by mutableStateOf(false) + private var wasKeyboardClosingWithStream by mutableStateOf(false) + + val hasVisibleStream: Boolean + get() = heightDp > 0.dp + + /** + * Returns the effective toolbar alpha, accounting for the bridge state + * between keyboard closing and stream becoming visible. + */ + val effectiveAlpha: Float + get() = if (hasVisibleStream || isKeyboardClosingWithStream || wasKeyboardClosingWithStream) alpha.value else 1f + + suspend fun updateAnimation(hasVisibleStream: Boolean, keyboardClosingWithStream: Boolean) { + isKeyboardClosingWithStream = keyboardClosingWithStream + if (keyboardClosingWithStream) wasKeyboardClosingWithStream = true + if (hasVisibleStream) wasKeyboardClosingWithStream = false + + when { + keyboardClosingWithStream -> { + alpha.animateTo(0f, tween(durationMillis = 150)) + } + hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { + prevHasVisibleStream = hasVisibleStream + alpha.snapTo(0f) + alpha.animateTo(1f, tween(durationMillis = 350)) + } + !hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { + prevHasVisibleStream = hasVisibleStream + alpha.snapTo(0f) + } + } + } +} + +@Composable +private fun rememberStreamToolbarState( + currentStream: UserName?, + isKeyboardVisible: Boolean, + imeTargetBottom: Int, +): StreamToolbarState { + val state = remember { StreamToolbarState(alpha = Animatable(0f)) } + + val hasVisibleStream = currentStream != null && state.heightDp > 0.dp + val isKeyboardClosingWithStream = currentStream != null && isKeyboardVisible && imeTargetBottom == 0 + + LaunchedEffect(hasVisibleStream, isKeyboardClosingWithStream) { + state.updateAnimation(hasVisibleStream, isKeyboardClosingWithStream) + } + LaunchedEffect(currentStream) { + if (currentStream == null) state.heightDp = 0.dp + } + + return state +} + diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index 746f78e43..09dafdc12 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -93,7 +93,7 @@ fun MainScreenEventHandler( } } - // Handle Login Result + // Handle Login Result — from direct login via Settings/Toolbar val navBackStackEntry = navController.currentBackStackEntryAsState().value val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } LaunchedEffect(loginSuccess) { @@ -113,6 +113,16 @@ fun MainScreenEventHandler( } } + // Handle login that happened during onboarding — the login_success saved state + // is on Onboarding's backstack entry (popped before MainScreen), so we need to + // reconnect if credentials exist but the connection is still anonymous. + LaunchedEffect(Unit) { + if (preferenceStore.isLoggedIn) { + channelManagementViewModel.reconnect() + mainScreenViewModel.reloadGlobalData() + } + } + // Handle data loading errors val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() LaunchedEffect(loadingState) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index dc4eaa09a..4c699aef7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -4,11 +4,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 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.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -26,10 +30,12 @@ import org.koin.android.annotation.KoinViewModel * * This ViewModel only handles truly global concerns. */ +@OptIn(FlowPreview::class) @KoinViewModel class MainScreenViewModel( private val channelDataCoordinator: ChannelDataCoordinator, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { // Only expose truly global state @@ -49,6 +55,12 @@ class MainScreenViewModel( private val _gestureToolbarHidden = MutableStateFlow(false) val gestureToolbarHidden: StateFlow = _gestureToolbarHidden.asStateFlow() + // Keyboard height persistence — debounced to avoid thrashing during animation + private val _keyboardHeightUpdates = MutableSharedFlow(extraBufferCapacity = 1) + + private val _keyboardHeightPx = MutableStateFlow(0) + val keyboardHeightPx: StateFlow = _keyboardHeightPx.asStateFlow() + fun setGestureInputHidden(hidden: Boolean) { _gestureInputHidden.value = hidden } fun setGestureToolbarHidden(hidden: Boolean) { _gestureToolbarHidden.value = hidden } @@ -58,8 +70,31 @@ class MainScreenViewModel( } init { - // Load global data once at startup channelDataCoordinator.loadGlobalData() + + viewModelScope.launch { + _keyboardHeightUpdates + .debounce(300) + .collect { (heightPx, isLandscape) -> + _keyboardHeightPx.value = heightPx + if (isLandscape) { + preferenceStore.keyboardHeightLandscape = heightPx + } else { + preferenceStore.keyboardHeightPortrait = heightPx + } + } + } + } + + 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 reloadGlobalData() { @@ -80,3 +115,5 @@ class MainScreenViewModel( channelDataCoordinator.retryDataLoading(dataFailures, chatFailures) } } + +private data class KeyboardHeightUpdate(val heightPx: Int, val isLandscape: Boolean) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt index 20028d05b..32707e223 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.main.compose +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import com.flxrs.dankchat.data.UserName import kotlinx.coroutines.flow.MutableStateFlow @@ -65,14 +66,14 @@ class SheetNavigationViewModel : ViewModel() { sealed interface FullScreenSheetState { data object Closed : FullScreenSheetState - data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState + @Immutable data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState data object Mention : FullScreenSheetState data object Whisper : FullScreenSheetState - data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState + @Immutable data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState } sealed interface InputSheetState { data object Closed : InputSheetState data object EmoteMenu : InputSheetState - data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState + @Immutable data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt index 504435c4f..155180b5c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt @@ -8,6 +8,7 @@ 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.launch import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn @@ -55,9 +56,16 @@ class OnboardingDataStore( initialValue = runBlocking { settings.first() } ) + private val persistScope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) + fun current() = currentSettings.value suspend fun update(transform: suspend (OnboardingSettings) -> OnboardingSettings) { runCatching { dataStore.updateData(transform) } } + + /** Fire-and-forget update that survives caller cancellation (e.g. config change). */ + fun updateAsync(transform: suspend (OnboardingSettings) -> OnboardingSettings) { + persistScope.launch { update(transform) } + } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt index 9dd3b29b5..aacae4f01 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt @@ -34,6 +34,9 @@ class FeatureTourController( var isActive by mutableStateOf(false) private set + /** Set synchronously on completion to prevent restart from stale datastore reads. */ + private var hasCompleted = false + var currentStepIndex by mutableIntStateOf(0) private set @@ -64,7 +67,7 @@ class FeatureTourController( val recoveryFabTooltipState = TooltipState(isPersistent = true) fun start() { - if (isActive) return + if (isActive || hasCompleted) return isActive = true val settings = onboardingDataStore.current() // Only resume persisted step if it belongs to the current tour (gap == 1). @@ -93,7 +96,7 @@ class FeatureTourController( return } else -> { - scope.launch { onboardingDataStore.update { it.copy(featureTourStep = currentStepIndex) } } + onboardingDataStore.updateAsync { it.copy(featureTourStep = currentStepIndex) } applyStepSideEffects() } } @@ -125,11 +128,10 @@ class FeatureTourController( private fun completeTour() { isActive = false + hasCompleted = true onRestoreInput?.invoke() onComplete?.invoke() - scope.launch { - onboardingDataStore.update { it.copy(featureTourVersion = CURRENT_TOUR_VERSION, featureTourStep = 0) } - } + onboardingDataStore.updateAsync { it.copy(featureTourVersion = CURRENT_TOUR_VERSION, featureTourStep = 0) } } private fun showCurrentTooltip() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt index da40ca3a8..3e43ed1cd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt @@ -6,11 +6,8 @@ import androidx.compose.runtime.Stable 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 com.flxrs.dankchat.onboarding.OnboardingDataStore -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch @Immutable sealed interface PostOnboardingStep { @@ -30,7 +27,6 @@ sealed interface PostOnboardingStep { @Stable class PostOnboardingCoordinator( private val onboardingDataStore: OnboardingDataStore, - private val scope: CoroutineScope, ) { var step by mutableStateOf(PostOnboardingStep.Idle) private set @@ -49,13 +45,13 @@ class PostOnboardingCoordinator( fun onAddedChannelFromToolbar() { if (toolbarHintDone) return toolbarHintDone = true - scope.launch { onboardingDataStore.update { it.copy(hasShownToolbarHint = true) } } + onboardingDataStore.updateAsync { it.copy(hasShownToolbarHint = true) } } fun onToolbarHintDismissed() { if (toolbarHintDone) return // idempotent for external-dismiss handler toolbarHintDone = true - scope.launch { onboardingDataStore.update { it.copy(hasShownToolbarHint = true) } } + onboardingDataStore.updateAsync { it.copy(hasShownToolbarHint = true) } val settings = onboardingDataStore.current() step = when { settings.featureTourVersion < CURRENT_TOUR_VERSION && !isEmpty -> PostOnboardingStep.FeatureTour @@ -84,8 +80,7 @@ class PostOnboardingCoordinator( @Composable fun rememberPostOnboardingCoordinator(onboardingDataStore: OnboardingDataStore): PostOnboardingCoordinator { - val scope = rememberCoroutineScope() return remember(onboardingDataStore) { - PostOnboardingCoordinator(onboardingDataStore, scope) + PostOnboardingCoordinator(onboardingDataStore) } } 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 index a6e676259..e1c615c36 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -160,8 +160,10 @@ fun rememberRoundedCornerHorizontalPadding(fallback: Dp = 0.dp): PaddingValues { } 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) } From 409d75c83766d1133b005cd9c55b1bfca22dc91e Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 14 Mar 2026 23:29:16 +0100 Subject: [PATCH 039/349] refactor(auth): Extract auth state into AuthDataStore and AuthStateCoordinator --- .../com/flxrs/dankchat/DankChatViewModel.kt | 97 +++--------- .../com/flxrs/dankchat/auth/AuthDataStore.kt | 142 ++++++++++++++++++ .../com/flxrs/dankchat/auth/AuthSettings.kt | 17 +++ .../dankchat/auth/AuthStateCoordinator.kt | 126 ++++++++++++++++ .../dankchat/data/api/auth/AuthApiClient.kt | 4 +- .../data/api/eventapi/EventSubManager.kt | 6 +- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 68 ++++----- .../dankchat/data/repo/RepliesRepository.kt | 6 +- .../data/repo/channel/ChannelRepository.kt | 12 +- .../dankchat/data/repo/chat/ChatRepository.kt | 6 +- .../data/repo/command/CommandRepository.kt | 14 +- .../dankchat/data/repo/data/DataRepository.kt | 8 +- .../data/repo/emote/EmoteRepository.kt | 8 + .../data/repo/stream/StreamDataRepository.kt | 4 +- .../data/twitch/chat/ChatConnection.kt | 8 +- .../twitch/command/TwitchCommandRepository.kt | 8 +- .../data/twitch/pubsub/PubSubManager.kt | 12 +- .../com/flxrs/dankchat/di/ConnectionModule.kt | 10 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 8 +- .../dankchat/domain/ChannelDataCoordinator.kt | 6 +- .../flxrs/dankchat/login/LoginViewModel.kt | 20 ++- .../dankchat/login/compose/LoginScreen.kt | 1 - .../com/flxrs/dankchat/main/MainActivity.kt | 19 +-- .../flxrs/dankchat/main/compose/MainScreen.kt | 2 - .../main/compose/MainScreenEventHandler.kt | 74 +++------ .../dankchat/onboarding/OnboardingScreen.kt | 13 +- .../onboarding/OnboardingViewModel.kt | 18 ++- .../preferences/DankChatPreferenceStore.kt | 91 ++++------- .../customlogin/CustomLoginViewModel.kt | 22 +-- 29 files changed, 513 insertions(+), 317 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/auth/AuthSettings.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 99ea33fa8..8408325c3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -1,52 +1,50 @@ 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.auth.AuthDataStore +import com.flxrs.dankchat.auth.AuthEvent +import com.flxrs.dankchat.auth.AuthStateCoordinator import com.flxrs.dankchat.data.repo.chat.ChatRepository 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.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.Flow +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 kotlin.time.Duration.Companion.seconds -import android.webkit.CookieManager -import android.webkit.WebStorage -import com.flxrs.dankchat.data.repo.IgnoresRepository -import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository - @KoinViewModel class DankChatViewModel( private val chatRepository: ChatRepository, - private val dankChatPreferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val authApiClient: AuthApiClient, private val dataRepository: DataRepository, - private val ignoresRepository: IgnoresRepository, - private val userStateRepository: UserStateRepository, - private val emoteUsageRepository: EmoteUsageRepository, + private val authStateCoordinator: AuthStateCoordinator, ) : ViewModel() { val serviceEvents = dataRepository.serviceEvents private var initialConnectionStarted = false val activeChannel = chatRepository.activeChannel - val isLoggedIn = dankChatPreferenceStore.isLoggedInFlow - - private val _validationResult = Channel(Channel.BUFFERED) - val validationResult get() = _validationResult.receiveAsFlow() + val isLoggedIn: Flow = authDataStore.settings + .map { it.isLoggedIn } + .distinctUntilChanged() + + // Legacy compatibility for MainFragment — maps AuthEvent to ValidationResult. + // Remove when fragments are deleted. + val validationResult: Flow = authStateCoordinator.events.map { event -> + when (event) { + is AuthEvent.LoggedIn -> ValidationResult.User(event.userName) + is AuthEvent.ScopesOutdated -> ValidationResult.IncompleteScopes(event.userName) + AuthEvent.TokenInvalid -> ValidationResult.TokenInvalid + AuthEvent.ValidationFailed -> ValidationResult.Failure + } + } val isTrueDarkModeEnabled get() = appearanceSettingsDataStore.current().trueDarkTheme val keepScreenOn = appearanceSettingsDataStore.settings @@ -59,9 +57,7 @@ class DankChatViewModel( init { viewModelScope.launch { - if (dankChatPreferenceStore.isLoggedIn) { - validateUser() - } + authStateCoordinator.validateOnStartup() initialConnectionStarted = true chatRepository.connectAndJoin() } @@ -77,55 +73,12 @@ class DankChatViewModel( } fun checkLogin() { - if (dankChatPreferenceStore.isLoggedIn && dankChatPreferenceStore.oAuthKey.isNullOrBlank()) { - dankChatPreferenceStore.clearLogin() + if (authDataStore.isLoggedIn && authDataStore.oAuthKey.isNullOrBlank()) { + authStateCoordinator.logout() } } fun clearDataForLogout() { - CookieManager.getInstance().removeAllCookies(null) - WebStorage.getInstance().deleteAllData() - - dankChatPreferenceStore.clearLogin() - userStateRepository.clear() - - chatRepository.closeAndReconnect() - ignoresRepository.clearIgnores() - viewModelScope.launch { - emoteUsageRepository.clearUsages() - } - } - - 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() } - ) - _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 - } - } - - companion object { - private val TAG = DankChatViewModel::class.java.simpleName + authStateCoordinator.logout() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt new file mode 100644 index 000000000..5295ae1f9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt @@ -0,0 +1,142 @@ +package com.flxrs.dankchat.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 { + return 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/auth/AuthSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthSettings.kt new file mode 100644 index 000000000..28e2794b5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthSettings.kt @@ -0,0 +1,17 @@ +package com.flxrs.dankchat.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/auth/AuthStateCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt new file mode 100644 index 000000000..461ab5c39 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt @@ -0,0 +1,126 @@ +package com.flxrs.dankchat.auth + +import android.util.Log +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.ChatRepository +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.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 +} + +@Single +class AuthStateCoordinator( + private val authDataStore: AuthDataStore, + private val chatRepository: ChatRepository, + private val channelDataCoordinator: ChannelDataCoordinator, + private val emoteRepository: EmoteRepository, + private val authApiClient: AuthApiClient, + private val ignoresRepository: IgnoresRepository, + private val userStateRepository: UserStateRepository, + private val emoteUsageRepository: EmoteUsageRepository, + 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 DankChatViewModel.init). + scope.launch { + authDataStore.settings + .distinctUntilChangedBy { it.isLoggedIn to it.oAuthKey } + .drop(1) + .collect { settings -> + when { + settings.isLoggedIn -> { + chatRepository.reconnect() + channelDataCoordinator.reloadGlobalData() + settings.userName?.let { name -> + _events.send(AuthEvent.LoggedIn(UserName(name))) + } + } + + else -> { + emoteRepository.clearTwitchEmotes() + chatRepository.closeAndReconnect() + } + } + } + } + } + + suspend fun validateOnStartup() { + if (!authDataStore.isLoggedIn) return + + val token = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return + 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 -> { + authDataStore.clearLogin() + AuthEvent.TokenInvalid + } + + else -> { + Log.e(TAG, "Failed to validate token: ${throwable.message}") + AuthEvent.ValidationFailed + } + } + } + ) + _events.send(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() + } + } + + companion object { + private val TAG = AuthStateCoordinator::class.java.simpleName + } +} 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 5f2fe4281..35e9931ed 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.auth.AuthSettings import com.flxrs.dankchat.utils.extensions.decodeOrNull import io.ktor.client.call.body import io.ktor.client.statement.bodyAsText @@ -69,6 +69,6 @@ class AuthApiClient(private val authApi: AuthApi, private val json: Json) { "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 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/eventapi/EventSubManager.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubManager.kt index a64d33a4d..4f06e87d9 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 @@ -4,7 +4,7 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -18,7 +18,7 @@ class EventSubManager( private val eventSubClient: EventSubClient, private val channelRepository: ChannelRepository, private val userStateRepository: UserStateRepository, - private val preferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, developerSettingsDataStore: DeveloperSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { @@ -36,7 +36,7 @@ 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) 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 3c3b90266..82060ca65 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 @@ -10,7 +10,7 @@ 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.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix import io.ktor.client.HttpClient import io.ktor.client.request.bearerAuth @@ -25,10 +25,10 @@ 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) { suspend fun getUsersByName(logins: List): HttpResponse? = ktorClient.get("users") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) logins.forEach { parameter("login", it) @@ -36,7 +36,7 @@ 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) ids.forEach { parameter("id", it) @@ -44,7 +44,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) if (targetUserId != null) { @@ -59,7 +59,7 @@ 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) channels.forEach { parameter("user_login", it) @@ -67,7 +67,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun getUserBlocks(userId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("users/blocks") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", userId) parameter("first", first) @@ -77,19 +77,19 @@ 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -98,7 +98,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun getModerators(broadcasterUserId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("moderation/moderators") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("first", first) @@ -108,21 +108,21 @@ 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("from_user_id", fromUserId) parameter("to_user_id", toUserId) @@ -131,7 +131,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun getVips(broadcasterUserId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("channels/vips") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("first", first) @@ -141,21 +141,21 @@ 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -164,7 +164,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun deleteBan(broadcasterUserId: UserId, moderatorUserId: UserId, targetUserId: UserId): HttpResponse? = ktorClient.delete("moderation/bans") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -172,7 +172,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun deleteMessages(broadcasterUserId: UserId, moderatorUserId: UserId, messageId: String?): HttpResponse? = ktorClient.delete("moderation/chat") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -182,41 +182,41 @@ 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -225,20 +225,20 @@ 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("from_broadcaster_id", broadcasterUserId) parameter("to_broadcaster_id", targetUserId) @@ -247,7 +247,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun putShieldMode(broadcasterUserId: UserId, moderatorUserId: UserId, request: ShieldModeRequestDto): HttpResponse? = ktorClient.put("moderation/shield_mode") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -256,20 +256,20 @@ 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("id", id) } suspend fun getUserEmotes(userId: UserId, after: String? = null): HttpResponse? = ktorClient.get("chat/emotes/user") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("user_id", userId) if (after != null) { @@ -278,7 +278,7 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun getChannelEmotes(broadcasterId: UserId): HttpResponse? = ktorClient.get("chat/emotes") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterId) } 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 cf387437c..eae7998d3 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 @@ -9,7 +9,7 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.utils.extensions.replaceIf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,7 +20,7 @@ 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>() @@ -137,7 +137,7 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS } private fun PrivMessage.isParticipating(): Boolean { - return name == dankChatPreferenceStore.userName || (PARENT_MESSAGE_LOGIN_TAG in tags && tags[PARENT_MESSAGE_LOGIN_TAG] == dankChatPreferenceStore.userName?.value) + return name == authDataStore.userName || (PARENT_MESSAGE_LOGIN_TAG in tags && tags[PARENT_MESSAGE_LOGIN_TAG] == authDataStore.userName?.value) } private fun PrivMessage.stripLeadingReplyMention(): PrivMessage { 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 6e82aaf01..840c39d09 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 @@ -9,7 +9,7 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.utils.extensions.firstValue import com.flxrs.dankchat.utils.extensions.firstValueOrNull import kotlinx.coroutines.Dispatchers @@ -24,7 +24,7 @@ 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 channelCache = ConcurrentHashMap() @@ -38,7 +38,7 @@ class ChannelRepository( } val channel = when { - dankChatPreferenceStore.isLoggedIn -> helixApiClient.getUserByName(name) + authDataStore.isLoggedIn -> helixApiClient.getUserByName(name) .getOrNull() ?.let { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } @@ -58,7 +58,7 @@ class ChannelRepository( return cached } - if (!dankChatPreferenceStore.isLoggedIn) { + if (!authDataStore.isLoggedIn) { return null } @@ -104,7 +104,7 @@ class ChannelRepository( val cached = ids.mapNotNull { getCachedChannelByIdOrNull(it) } val cachedIds = cached.mapTo(mutableSetOf(), Channel::id) val remaining = ids.filterNot { it in cachedIds } - if (remaining.isEmpty() || !dankChatPreferenceStore.isLoggedIn) { + if (remaining.isEmpty() || !authDataStore.isLoggedIn) { return@withContext cached } @@ -121,7 +121,7 @@ class ChannelRepository( 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 } 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 9209b7776..8bf5afde4 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 @@ -42,6 +42,7 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.extensions.INVISIBLE_CHAR @@ -95,6 +96,7 @@ class ChatRepository( private val repliesRepository: RepliesRepository, private val userStateRepository: UserStateRepository, private val usersRepository: UsersRepository, + private val authDataStore: AuthDataStore, private val dankChatPreferenceStore: DankChatPreferenceStore, private val chatSettingsDataStore: ChatSettingsDataStore, private val pubSubManager: PubSubManager, @@ -374,7 +376,7 @@ class ChatRepository( 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, @@ -695,7 +697,7 @@ class ChatRepository( } if (message is PrivMessage) { - if (message.name == dankChatPreferenceStore.userName) { + if (message.name == authDataStore.userName) { val previousLastMessage = lastMessage[message.channel].orEmpty() val lastMessageWasCommand = previousLastMessage.startsWith('.') || previousLastMessage.startsWith('/') if (!lastMessageWasCommand && previousLastMessage.withoutInvisibleChar != message.originalMessage.withoutInvisibleChar) { 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..f6594f291 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 @@ -13,7 +13,7 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.CustomCommand import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore @@ -45,7 +45,7 @@ class CommandRepository( private val supibotApiClient: SupibotApiClient, private val chatSettingsDataStore: ChatSettingsDataStore, private val developerSettingsDataStore: DeveloperSettingsDataStore, - private val preferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, dispatchersProvider: DispatchersProvider, ) { @@ -82,7 +82,7 @@ class CommandRepository( 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) { + if (!authDataStore.isLoggedIn) { return CommandResult.NotFound } @@ -130,8 +130,8 @@ 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 } + val currentUserId = authDataStore.userIdString + ?.takeIf { authDataStore.isLoggedIn } ?: return CommandResult.AcceptedTwitchCommand( command = twitchCommand, response = "You must be logged in to use the $trigger command" @@ -144,7 +144,7 @@ class CommandRepository( } suspend fun loadSupibotCommands() = withContext(Dispatchers.Default) { - if (!preferenceStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { + if (!authDataStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { return@withContext } @@ -198,7 +198,7 @@ class CommandRepository( } private suspend fun getSupibotUserAliases(): List { - val user = preferenceStore.userName ?: return emptyList() + val user = authDataStore.userName ?: return emptyList() return supibotApiClient.getSupibotUserAliases(user) .getOrNull() ?.let { (data) -> 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 e1f7e536f..6b734214e 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 @@ -21,7 +21,7 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.VisibleThirdPartyEmotes import com.flxrs.dankchat.utils.extensions.measureTimeAndLog @@ -54,7 +54,7 @@ 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, ) { @@ -130,7 +130,7 @@ class DataRepository( suspend fun loadGlobalBadges(): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "global badges") { val result = when { - dankChatPreferenceStore.isLoggedIn -> helixApiClient.getGlobalBadges().map { it.toBadgeSets() } + 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 } @@ -162,7 +162,7 @@ class DataRepository( suspend fun loadChannelBadges(channel: UserName, id: UserId): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "channel badges for #$id") { val result = when { - dankChatPreferenceStore.isLoggedIn -> helixApiClient.getChannelBadges(id).map { it.toBadgeSets() } + 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) } 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 b349252f1..eafeffe13 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 @@ -97,6 +97,14 @@ class EmoteRepository( channelEmoteStates.remove(channel) } + /** Clears user-specific Twitch emotes (subscriber, bit, follower) from global state. */ + 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 globalState = globalEmoteState.value val channelState = channelEmoteStates[channel]?.value ?: ChannelEmoteState() 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 index 45aef8c1b..bba796045 100644 --- 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 @@ -4,6 +4,7 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.data.DataRepository import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.main.StreamData +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils @@ -22,6 +23,7 @@ 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, @@ -37,7 +39,7 @@ class StreamDataRepository( scope.launch { val settings = streamsSettingsDataStore.settings.first() - if (!dankChatPreferenceStore.isLoggedIn || !settings.fetchStreams) { + if (!authDataStore.isLoggedIn || !settings.fetchStreams) { return@launch } 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..3b2335600 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 @@ -5,8 +5,8 @@ import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.irc.IrcMessage import com.flxrs.dankchat.data.toUserName +import com.flxrs.dankchat.auth.AuthDataStore 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.ktor.util.collections.ConcurrentSet @@ -52,7 +52,7 @@ sealed interface ChatEvent { class ChatConnection( private val chatConnectionType: ChatConnectionType, private val client: OkHttpClient, - private val preferences: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, dispatchersProvider: DispatchersProvider, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) @@ -148,8 +148,8 @@ class ChatConnection( fun connect() { if (connected || connecting) return - currentUserName = preferences.userName - currentOAuth = preferences.oAuthKey + currentUserName = authDataStore.userName + currentOAuth = authDataStore.oAuthKey awaitingPong = false connecting = true socket = client.newWebSocket(request, TwitchWebSocketListener()) 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..d1d2df367 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 @@ -19,7 +19,7 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.utils.DateTimeUtils import org.koin.core.annotation.Single import java.util.UUID @@ -27,13 +27,13 @@ import java.util.UUID @Single class TwitchCommandRepository( private val helixApiClient: HelixApiClient, - private val dankChatPreferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, ) { fun isIrcCommand(trigger: String): Boolean = trigger in ALLOWED_IRC_COMMAND_TRIGGERS fun getAvailableCommandTriggers(room: RoomState, userState: UserState): List { - val currentUserId = dankChatPreferenceStore.userIdString ?: return emptyList() + val currentUserId = authDataStore.userIdString ?: return emptyList() return when { room.channelId == currentUserId -> TwitchCommand.ALL_COMMANDS room.channel in userState.moderationChannels -> TwitchCommand.MODERATOR_COMMANDS @@ -53,7 +53,7 @@ class TwitchCommandRepository( } suspend fun handleTwitchCommand(command: TwitchCommand, context: CommandContext): CommandResult { - val currentUserId = dankChatPreferenceStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( + val currentUserId = authDataStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( command = command, response = "You must be logged in to use the ${context.trigger} command" ) 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..82ed72376 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,5 +1,6 @@ package com.flxrs.dankchat.data.twitch.pubsub +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.di.DispatchersProvider @@ -25,6 +26,7 @@ import org.koin.core.annotation.Single class PubSubManager( private val channelRepository: ChannelRepository, private val developerSettingsDataStore: DeveloperSettingsDataStore, + private val authDataStore: AuthDataStore, private val preferenceStore: DankChatPreferenceStore, @Named(type = WebSocketOkHttpClient::class) private val client: OkHttpClient, private val json: Json, @@ -43,11 +45,11 @@ class PubSubManager( get() = connections.any { it.connected && it.hasWhisperTopic } fun start() { - if (!preferenceStore.isLoggedIn) { + if (!authDataStore.isLoggedIn) { return } - val userId = preferenceStore.userIdString ?: return + val userId = authDataStore.userIdString ?: return val channels = preferenceStore.channels scope.launch { @@ -86,11 +88,11 @@ class PubSubManager( } fun addChannel(channel: UserName) = scope.launch { - if (!preferenceStore.isLoggedIn) { + if (!authDataStore.isLoggedIn) { return@launch } - val userId = preferenceStore.userIdString ?: return@launch + val userId = authDataStore.userIdString ?: return@launch val channelId = channelRepository.getChannel(channel)?.id ?: return@launch val usePubsub = developerSettingsDataStore.settings.first().shouldUsePubSub @@ -119,7 +121,7 @@ class PubSubManager( } private fun listen(topics: Set) { - val oAuth = preferenceStore.oAuthKey?.withoutOAuthPrefix ?: return + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return val remainingTopics = connections.fold(topics) { acc, conn -> conn.listen(acc) } 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..b5381800d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt @@ -1,8 +1,8 @@ package com.flxrs.dankchat.di +import com.flxrs.dankchat.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 org.koin.core.annotation.Module import org.koin.core.annotation.Named @@ -19,14 +19,14 @@ class ConnectionModule { fun provideReadConnection( @Named(type = WebSocketOkHttpClient::class) client: OkHttpClient, dispatchersProvider: DispatchersProvider, - preferenceStore: DankChatPreferenceStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Read, client, preferenceStore, dispatchersProvider) + authDataStore: AuthDataStore, + ): ChatConnection = ChatConnection(ChatConnectionType.Read, client, authDataStore, dispatchersProvider) @Single @Named(type = WriteConnection::class) fun provideWriteConnection( @Named(type = WebSocketOkHttpClient::class) client: OkHttpClient, dispatchersProvider: DispatchersProvider, - preferenceStore: DankChatPreferenceStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Write, client, preferenceStore, dispatchersProvider) + authDataStore: AuthDataStore, + ): ChatConnection = ChatConnection(ChatConnectionType.Write, client, authDataStore, dispatchersProvider) } 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..77f661640 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -11,7 +11,7 @@ import com.flxrs.dankchat.data.api.helix.HelixApi 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp @@ -115,12 +115,12 @@ class NetworkModule { }) @Single - fun provideHelixApi(ktorClient: HttpClient, preferenceStore: DankChatPreferenceStore) = HelixApi(ktorClient.config { + fun provideHelixApi(ktorClient: HttpClient, authDataStore: AuthDataStore) = HelixApi(ktorClient.config { defaultRequest { url(HELIX_BASE_URL) - header("Client-ID", preferenceStore.clientId) + header("Client-ID", authDataStore.clientId) } - }, preferenceStore) + }, authDataStore) @Single fun provideBadgesApi(ktorClient: HttpClient) = BadgesApi(ktorClient.config { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 20a41bc46..ac2b3ee5b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -6,6 +6,7 @@ import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.data.state.ChannelLoadingState import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.data.repo.chat.ChatLoadingFailure import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep @@ -32,6 +33,7 @@ class ChannelDataCoordinator( private val chatRepository: ChatRepository, private val dataRepository: DataRepository, private val userStateRepository: UserStateRepository, + private val authDataStore: AuthDataStore, private val preferenceStore: DankChatPreferenceStore, dispatchersProvider: DispatchersProvider ) { @@ -89,8 +91,8 @@ class ChannelDataCoordinator( chatRepository.reparseAllEmotesAndBadges() // Load user emotes if logged in — only block on first page, rest loads async - if (preferenceStore.isLoggedIn) { - val userId = preferenceStore.userIdString + if (authDataStore.isLoggedIn) { + val userId = authDataStore.userIdString if (userId != null) { val firstPageLoaded = CompletableDeferred() launch { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt index f01c6b383..e0976df82 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt @@ -3,9 +3,9 @@ package com.flxrs.dankchat.login import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.auth.AuthDataStore 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 @@ -14,7 +14,7 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class LoginViewModel( private val authApiClient: AuthApiClient, - private val dankChatPreferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, ) : ViewModel() { data class TokenParseEvent(val successful: Boolean) @@ -44,15 +44,13 @@ class LoginViewModel( 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 - } - + 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/login/compose/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt index 50e5c33e1..1f3cb7bad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt @@ -56,7 +56,6 @@ fun LoginScreen( LaunchedEffect(Unit) { viewModel.events.collect { event -> if (event.successful) { - navController.previousBackStackEntry?.savedStateHandle?.set("login_success", true) onLoginSuccess() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 16bd8bae6..bd370dd5e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -51,7 +51,7 @@ import androidx.navigation.toRoute import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R -import com.flxrs.dankchat.ValidationResult +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.notification.NotificationService import com.flxrs.dankchat.data.repo.data.ServiceEvent @@ -222,16 +222,6 @@ class MainActivity : AppCompatActivity() { } private fun setupComposeUi() { - lifecycleScope.launch { - viewModel.validationResult.collect { result -> - when (result) { - is ValidationResult.User -> mainEventBus.emitEvent(MainEvent.LoginValidated(result.username)) - is ValidationResult.IncompleteScopes -> mainEventBus.emitEvent(MainEvent.LoginOutdated(result.username)) - ValidationResult.TokenInvalid -> mainEventBus.emitEvent(MainEvent.LoginTokenInvalid) - ValidationResult.Failure -> mainEventBus.emitEvent(MainEvent.LoginValidationFailed) - } - } - } setContent { DankChatTheme { val navController = rememberNavController() @@ -253,11 +243,7 @@ class MainActivity : AppCompatActivity() { exitTransition = { fadeOut(animationSpec = tween(90)) }, popEnterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, popExitTransition = { fadeOut(animationSpec = tween(90)) } - ) { backStackEntry -> - val loginSuccess = backStackEntry - .savedStateHandle - .get("login_success") == true - + ) { OnboardingScreen( onNavigateToLogin = { navController.navigate(Login) @@ -267,7 +253,6 @@ class MainActivity : AppCompatActivity() { popUpTo(Onboarding) { inclusive = true } } }, - loginSuccess = loginSuccess, ) } composable
( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 3f839cbc0..87c6de126 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -288,14 +288,12 @@ fun MainScreen( val inputSheetState by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() MainScreenEventHandler( - navController = navController, resources = resources, snackbarHostState = snackbarHostState, mainEventBus = mainEventBus, dialogViewModel = dialogViewModel, chatInputViewModel = chatInputViewModel, channelTabViewModel = channelTabViewModel, - channelManagementViewModel = channelManagementViewModel, mainScreenViewModel = mainScreenViewModel, preferenceStore = preferenceStore, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index 09dafdc12..39b93cb79 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -10,33 +10,31 @@ import androidx.compose.material3.SnackbarResult 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.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.compose.currentBackStackEntryAsState import com.flxrs.dankchat.R +import com.flxrs.dankchat.auth.AuthEvent +import com.flxrs.dankchat.auth.AuthStateCoordinator import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.main.MainActivity import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.preferences.DankChatPreferenceStore import kotlinx.coroutines.launch +import org.koin.compose.koinInject @Composable fun MainScreenEventHandler( - navController: NavController, resources: Resources, snackbarHostState: SnackbarHostState, mainEventBus: MainEventBus, dialogViewModel: DialogStateViewModel, chatInputViewModel: ChatInputViewModel, channelTabViewModel: ChannelTabViewModel, - channelManagementViewModel: ChannelManagementViewModel, mainScreenViewModel: MainScreenViewModel, preferenceStore: DankChatPreferenceStore, ) { val context = LocalContext.current + val authStateCoordinator: AuthStateCoordinator = koinInject() // MainEventBus event collection LaunchedEffect(Unit) { @@ -64,24 +62,6 @@ fun MainScreenEventHandler( ?: resources.getString(R.string.snackbar_upload_failed) snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) } - is MainEvent.LoginValidated -> { - snackbarHostState.showSnackbar( - message = resources.getString(R.string.snackbar_login, event.username), - duration = SnackbarDuration.Short - ) - } - is MainEvent.LoginOutdated -> { - dialogViewModel.showLoginOutdated(event.username) - } - MainEvent.LoginTokenInvalid -> { - dialogViewModel.showLoginExpired() - } - MainEvent.LoginValidationFailed -> { - snackbarHostState.showSnackbar( - message = resources.getString(R.string.oauth_verify_failed), - duration = SnackbarDuration.Short - ) - } is MainEvent.OpenChannel -> { channelTabViewModel.selectTab( preferenceStore.channels.indexOf(event.channel) @@ -93,36 +73,32 @@ fun MainScreenEventHandler( } } - // Handle Login Result — from direct login via Settings/Toolbar - val navBackStackEntry = navController.currentBackStackEntryAsState().value - val loginSuccess by navBackStackEntry?.savedStateHandle?.getStateFlow("login_success", null)?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } - LaunchedEffect(loginSuccess) { - if (loginSuccess == true) { - channelManagementViewModel.reconnect() - mainScreenViewModel.reloadGlobalData() - navBackStackEntry?.savedStateHandle?.remove("login_success") - launch { - val name = preferenceStore.userName - val message = if (name != null) { - resources.getString(R.string.snackbar_login, name) - } else { - resources.getString(R.string.login) + // Collect auth events from AuthStateCoordinator + LaunchedEffect(Unit) { + authStateCoordinator.events.collect { event -> + when (event) { + is AuthEvent.LoggedIn -> { + snackbarHostState.showSnackbar( + message = resources.getString(R.string.snackbar_login, event.userName), + duration = SnackbarDuration.Short, + ) + } + is AuthEvent.ScopesOutdated -> { + dialogViewModel.showLoginOutdated(event.userName) + } + AuthEvent.TokenInvalid -> { + dialogViewModel.showLoginExpired() + } + AuthEvent.ValidationFailed -> { + snackbarHostState.showSnackbar( + message = resources.getString(R.string.oauth_verify_failed), + duration = SnackbarDuration.Short, + ) } - snackbarHostState.showSnackbar(message) } } } - // Handle login that happened during onboarding — the login_success saved state - // is on Onboarding's backstack entry (popped before MainScreen), so we need to - // reconnect if credentials exist but the connection is still anonymous. - LaunchedEffect(Unit) { - if (preferenceStore.isLoggedIn) { - channelManagementViewModel.reconnect() - mainScreenViewModel.reloadGlobalData() - } - } - // Handle data loading errors val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() LaunchedEffect(loadingState) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt index 8c2e4d0fc..43f0f7951 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt @@ -73,7 +73,6 @@ private const val PAGE_COUNT = 4 fun OnboardingScreen( onNavigateToLogin: () -> Unit, onComplete: () -> Unit, - loginSuccess: Boolean, modifier: Modifier = Modifier, ) { val viewModel: OnboardingViewModel = koinViewModel() @@ -83,18 +82,14 @@ fun OnboardingScreen( initialPage = state.initialPage, pageCount = { PAGE_COUNT }, ) - LaunchedEffect(pagerState.currentPage) { viewModel.setCurrentPage(pagerState.currentPage) } - LaunchedEffect(loginSuccess) { - if (loginSuccess) { - viewModel.onLoginCompleted() - // Auto-advance past login page - if (pagerState.currentPage == 1) { - pagerState.animateScrollToPage(2) - } + // Auto-advance past login page when login is detected by the ViewModel + LaunchedEffect(state.loginCompleted) { + if (state.loginCompleted && pagerState.currentPage == 1) { + pagerState.animateScrollToPage(2) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt index 8f73f2fc2..f92119ff0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt @@ -2,11 +2,14 @@ package com.flxrs.dankchat.onboarding import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.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 kotlinx.coroutines.runBlocking @@ -23,6 +26,7 @@ data class OnboardingState( @KoinViewModel class OnboardingViewModel( private val onboardingDataStore: OnboardingDataStore, + private val authDataStore: AuthDataStore, private val dankChatPreferenceStore: DankChatPreferenceStore, private val chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { @@ -32,7 +36,7 @@ class OnboardingViewModel( init { val savedPage = runBlocking { onboardingDataStore.current().onboardingPage } - val isLoggedIn = dankChatPreferenceStore.isLoggedIn + val isLoggedIn = authDataStore.isLoggedIn _state = MutableStateFlow( OnboardingState( initialPage = savedPage, @@ -43,6 +47,18 @@ class OnboardingViewModel( ) ) 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) { 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 ab4d719b5..58a879b93 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -8,12 +8,11 @@ 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.auth.AuthDataStore +import com.flxrs.dankchat.auth.AuthSettings 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.toUserNames import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.model.ChannelWithRename @@ -21,6 +20,8 @@ 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 +30,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,17 +38,11 @@ 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) } + // Legacy forwarding — auth state now lives in AuthDataStore. + // Remove when fragments are deleted. + 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() @@ -57,22 +53,25 @@ class DankChatPreferenceStore( 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) } + } + // Legacy int user ID — only used by MainFragment migration code 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) } @@ -95,29 +94,13 @@ class DankChatPreferenceStore( val secretDankerModeClicks: Int = SECRET_DANKER_MODE_CLICKS - val isLoggedInFlow: Flow = callbackFlow { - send(isLoggedIn) - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == LOGGED_IN_KEY) { - trySend(isLoggedIn) - } - } + val isLoggedInFlow: Flow = authDataStore.settings + .map { it.isLoggedIn } + .distinctUntilChanged() - dankChatPreferences.registerOnSharedPreferenceChangeListener(listener) - awaitClose { dankChatPreferences.unregisterOnSharedPreferenceChangeListener(listener) } - } - - 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) } - } + val currentUserAndDisplayFlow: Flow> = authDataStore.settings + .map { authDataStore.userName to authDataStore.displayName } + .distinctUntilChanged() fun formatViewersString(viewers: Int, uptime: String, category: String?): String { return when (category) { @@ -126,12 +109,8 @@ class DankChatPreferenceStore( } } - 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) + fun clearLogin() { + authDataStore.updateAsync { it.copy(oAuthKey = null, userName = null, displayName = null, userId = null, clientId = AuthSettings.DEFAULT_CLIENT_ID, isLoggedIn = false) } } fun removeChannel(channel: UserName): List { @@ -215,15 +194,9 @@ 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" @@ -232,7 +205,5 @@ class DankChatPreferenceStore( 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/developer/customlogin/CustomLoginViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginViewModel.kt index c43f1c33b..7c66685d2 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 @@ -1,9 +1,9 @@ package com.flxrs.dankchat.preferences.developer.customlogin +import com.flxrs.dankchat.auth.AuthDataStore 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.preferences.developer.customlogin.CustomLoginState.Default import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginState.Failure import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginState.Loading @@ -21,7 +21,7 @@ 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) @@ -68,14 +68,18 @@ 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() } From 86e27bfaaf6bf32865c0e1e2df3d79bcd2cbc93d Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 15 Mar 2026 13:56:59 +0100 Subject: [PATCH 040/349] feat(emotes): Add cheermote support with animated emotes --- .../chat/compose/ChatMessageMapper.kt | 21 ++++-- .../chat/compose/ChatMessageUiState.kt | 2 + .../chat/compose/messages/PrivMessage.kt | 12 ++++ .../messages/common/MessageTextBuilders.kt | 12 ++++ .../chat/emote/EmoteSheetViewModel.kt | 2 + .../compose/EmoteInfoComposeViewModel.kt | 2 + .../flxrs/dankchat/data/api/helix/HelixApi.kt | 7 ++ .../dankchat/data/api/helix/HelixApiClient.kt | 8 +++ .../data/api/helix/dto/CheermoteDto.kt | 37 ++++++++++ .../data/repo/data/DataLoadingStep.kt | 1 + .../dankchat/data/repo/data/DataRepository.kt | 13 ++++ .../data/repo/emote/EmoteRepository.kt | 69 ++++++++++++++++++- .../flxrs/dankchat/data/repo/emote/Emotes.kt | 2 + .../data/state/ChannelLoadingState.kt | 5 ++ .../data/twitch/emote/ChatMessageEmote.kt | 2 + .../data/twitch/emote/ChatMessageEmoteType.kt | 3 + .../data/twitch/emote/CheermoteSet.kt | 16 +++++ .../dankchat/domain/ChannelDataCoordinator.kt | 1 + .../dankchat/domain/ChannelDataLoader.kt | 9 ++- .../com/flxrs/dankchat/main/MainViewModel.kt | 1 + 20 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CheermoteDto.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/CheermoteSet.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 5ffa9e485..c561df98d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -314,17 +314,21 @@ object ChatMessageMapper { 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 = emoteGroup.first().code, + code = firstEmote.code, urls = emoteGroup.map { it.url }, position = position, isAnimated = hasAnimated, isTwitch = emoteGroup.any { it.isTwitch }, - scale = emoteGroup.first().scale, - emotes = emoteGroup + scale = firstEmote.scale, + emotes = emoteGroup, + cheerAmount = firstEmote.cheerAmount, + cheerColor = firstEmote.cheerColor?.let { Color(it) }, ) } @@ -428,20 +432,23 @@ object ChatMessageMapper { 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 = emoteGroup.first().code, + code = firstEmote.code, urls = emoteGroup.map { it.url }, position = position, isAnimated = hasAnimated, isTwitch = emoteGroup.any { it.isTwitch }, - scale = emoteGroup.first().scale, - emotes = emoteGroup + scale = firstEmote.scale, + emotes = emoteGroup, + cheerAmount = firstEmote.cheerAmount, + cheerColor = firstEmote.cheerColor?.let { Color(it) }, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 707829f91..460dec171 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -193,6 +193,8 @@ data class EmoteUi( val isTwitch: Boolean, val scale: Int, val emotes: List, // For click handling + val cheerAmount: Int? = null, + val cheerColor: Color? = null, ) /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 4598b6b52..7b67fd0d7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -217,6 +217,18 @@ private fun PrivMessageText( // 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()) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt index 59957e5be..189a5e189 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt @@ -73,6 +73,18 @@ fun AnnotatedString.Builder.appendMessageWithEmotes( // 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.length && !message[nextPos].isWhitespace()) { 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 index e0df1f965..e85f2598d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetViewModel.kt @@ -57,6 +57,7 @@ class EmoteSheetViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { is ChatMessageEmoteType.GlobalFFZEmote -> "$FFZ_BASE_LINK$id-$code" is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" + is ChatMessageEmoteType.Cheermote -> "$TWITCH_BASE_LINK$id" } } @@ -69,6 +70,7 @@ class EmoteSheetViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { 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 } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt index 9a5e29ecb..a484168d8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt @@ -58,6 +58,7 @@ class EmoteInfoComposeViewModel( is ChatMessageEmoteType.GlobalFFZEmote -> "$FFZ_BASE_LINK$id-$code" is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" + is ChatMessageEmoteType.Cheermote -> "$TWITCH_BASE_LINK$id" } } @@ -70,6 +71,7 @@ class EmoteInfoComposeViewModel( 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 } } 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 82060ca65..717783025 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 @@ -237,6 +237,13 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au contentType(ContentType.Application.Json) } + suspend fun getCheermotes(broadcasterId: UserId): HttpResponse? = ktorClient.get("bits/cheermotes") { + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterId) + contentType(ContentType.Application.Json) + } + suspend fun postShoutout(broadcasterUserId: UserId, targetUserId: UserId, moderatorUserId: UserId): HttpResponse? = ktorClient.post("chat/shoutouts") { val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) 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 41c8b0fcd..32e2dba24 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,6 +7,7 @@ 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.CheermoteSetDto 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.CommercialDto @@ -221,6 +222,13 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { .data } + suspend fun getCheermotes(broadcasterId: UserId): Result> = runCatching { + helixApi.getCheermotes(broadcasterId) + .throwHelixApiErrorOnFailure() + .body>() + .data + } + suspend fun postShoutout(broadcastUserId: UserId, targetUserId: UserId, moderatorUserId: UserId): Result = runCatching { helixApi.postShoutout(broadcastUserId, targetUserId, moderatorUserId) .throwHelixApiErrorOnFailure() 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/repo/data/DataLoadingStep.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingStep.kt index 2298bbd53..dc69f1c42 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 @@ -21,6 +21,7 @@ sealed interface 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 ChannelCheermotes(val channel: UserName, val channelId: UserId) : DataLoadingStep } fun List.toMergedStrings(): List { 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 6b734214e..fbe500ab9 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 @@ -216,6 +216,19 @@ class DataRepository( } } + suspend fun loadChannelCheermotes(channel: UserName, channelId: UserId): Result = withContext(Dispatchers.IO) { + if (!authDataStore.isLoggedIn) { + return@withContext Result.success(Unit) + } + + measureTimeAndLog(TAG, "cheermotes for #$channel") { + helixApiClient.getCheermotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } + .onSuccess { emoteRepository.setCheermotes(channel, it) } + .map { } + } + } + suspend fun loadGlobalFFZEmotes(): Result = withContext(Dispatchers.IO) { if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { return@withContext Result.success(Unit) 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 eafeffe13..830b43e92 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 @@ -16,6 +16,7 @@ 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.helix.HelixApiClient +import com.flxrs.dankchat.data.api.helix.dto.CheermoteSetDto import com.flxrs.dankchat.data.api.helix.HelixApiException import com.flxrs.dankchat.data.api.helix.HelixError import com.flxrs.dankchat.data.api.helix.dto.UserEmoteDto @@ -39,6 +40,8 @@ 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 @@ -169,8 +172,14 @@ class EmoteRepository( replyMentionOffset = replyMentionOffset ) val twitchEmoteCodes = twitchEmotes.mapTo(HashSet(twitchEmotes.size)) { it.code } - val thirdPartyEmotes = parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel).filterNot { it.code in twitchEmoteCodes } - val emotes = (twitchEmotes + thirdPartyEmotes) + val cheermotes = when { + message is PrivMessage && message.tags["bits"] != null -> parseCheermotes(appendedSpaceAdjustedMessage, channel) + else -> emptyList() + } + val cheermoteCodes = cheermotes.mapTo(HashSet(cheermotes.size)) { it.code } + val thirdPartyEmotes = parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel) + .filterNot { it.code in twitchEmoteCodes || it.code in cheermoteCodes } + val emotes = twitchEmotes + thirdPartyEmotes + cheermotes val (adjustedMessage, adjustedEmotes) = adjustOverlayEmotes(appendedSpaceAdjustedMessage, emotes) val messageWithEmotes = when (message) { @@ -572,6 +581,62 @@ class EmoteRepository( globalEmoteState.update { it.copy(sevenTvEmotes = sevenTvGlobalEmotes) } } + suspend fun setCheermotes(channel: UserName, cheermoteDtos: List) = withContext(Dispatchers.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 { + android.graphics.Color.parseColor(tier.color) + } catch (_: IllegalArgumentException) { + android.graphics.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 fun parseCheermotes(message: String, channel: UserName): List { + val cheermoteSets = channelEmoteStates[channel]?.value?.cheermoteSets + if (cheermoteSets.isNullOrEmpty()) return emptyList() + + var currentPosition = 0 + return buildList { + message.split(WHITESPACE_REGEX).forEach { word -> + 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 + this += 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, + ) + break + } + } + currentPosition += word.length + 1 + } + } + } + private val UserName?.twitchEmoteType: EmoteType get() = when { this == null || isGlobalTwitchChannel -> EmoteType.GlobalTwitchEmote 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 7900397ac..7fe9e019f 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,5 +1,6 @@ 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( @@ -14,6 +15,7 @@ data class ChannelEmoteState( val ffzEmotes: List = emptyList(), val bttvEmotes: List = emptyList(), val sevenTvEmotes: List = emptyList(), + val cheermoteSets: List = emptyList(), ) fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes { 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 index 6ebb49408..f5be03922 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -38,6 +38,11 @@ sealed interface ChannelLoadingFailure { 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 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..2e531b948 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 @@ -16,4 +16,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..603584602 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 @@ -26,6 +26,9 @@ sealed interface ChatMessageEmoteType : Parcelable { @Parcelize data class GlobalSevenTVEmote(val creator: DisplayName?, val baseName: String?) : ChatMessageEmoteType + + @Parcelize + data object Cheermote : ChatMessageEmoteType } fun EmoteType.toChatMessageEmoteType(): ChatMessageEmoteType? = when (this) { 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..c0e1a41dd --- /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, + @ColorInt val color: Int, + val animatedUrl: String, + val staticUrl: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index ac2b3ee5b..6bc15449a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -162,6 +162,7 @@ class ChannelDataCoordinator( 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) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index 0a79ac337..77ee57070 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -119,10 +119,17 @@ class ChannelDataLoader( 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() + sevenTvResult.await(), + cheermotesResult.await(), ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt index 1afe80a6f..988ac56e4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt @@ -497,6 +497,7 @@ class MainViewModel( 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) + is DataLoadingStep.ChannelCheermotes -> dataRepository.loadChannelCheermotes(it.step.channel, it.step.channelId) } } } + chatLoadingFailures.map { From 792ac0664327f404fa3be0a44d3327983e53bda1 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 15 Mar 2026 16:56:16 +0100 Subject: [PATCH 041/349] feat(automod): Add AutoMod held message support with allow/deny actions --- .../com/flxrs/dankchat/chat/ChatAdapter.kt | 11 + .../dankchat/chat/compose/ChatComposable.kt | 2 + .../chat/compose/ChatComposeViewModel.kt | 35 +++ .../chat/compose/ChatMessageMapper.kt | 56 ++++- .../chat/compose/ChatMessageUiState.kt | 25 ++ .../flxrs/dankchat/chat/compose/ChatScreen.kt | 15 ++ .../chat/compose/messages/AutomodMessage.kt | 237 ++++++++++++++++++ .../chat/compose/messages/PrivMessage.kt | 4 +- .../data/api/eventapi/EventSubClient.kt | 23 +- .../data/api/eventapi/EventSubManager.kt | 17 +- .../data/api/eventapi/EventSubMessage.kt | 16 ++ .../data/api/eventapi/EventSubTopic.kt | 42 ++++ .../eventapi/dto/EventSubSubscriptionType.kt | 4 + .../notification/AutomodMessageDto.kt | 134 ++++++++++ .../flxrs/dankchat/data/api/helix/HelixApi.kt | 8 + .../dankchat/data/api/helix/HelixApiClient.kt | 6 + .../data/api/helix/HelixApiException.kt | 2 + .../dto/ManageAutomodMessageRequestDto.kt | 14 ++ .../dankchat/data/repo/chat/ChatRepository.kt | 89 ++++++- .../data/repo/chat/UsersRepository.kt | 7 + .../twitch/command/TwitchCommandRepository.kt | 2 + .../data/twitch/message/AutomodMessage.kt | 23 ++ .../data/twitch/message/ModerationMessage.kt | 27 +- .../data/twitch/message/PrivMessage.kt | 2 +- .../main/res/drawable/ic_automod_badge.png | Bin 0 -> 218 bytes app/src/main/res/values-be-rBY/strings.xml | 20 ++ app/src/main/res/values-ca/strings.xml | 20 ++ app/src/main/res/values-cs/strings.xml | 20 ++ app/src/main/res/values-de-rDE/strings.xml | 20 ++ app/src/main/res/values-en-rAU/strings.xml | 20 ++ app/src/main/res/values-en-rGB/strings.xml | 20 ++ app/src/main/res/values-en/strings.xml | 20 ++ app/src/main/res/values-es-rES/strings.xml | 20 ++ app/src/main/res/values-fi-rFI/strings.xml | 20 ++ app/src/main/res/values-fr-rFR/strings.xml | 20 ++ app/src/main/res/values-hu-rHU/strings.xml | 20 ++ app/src/main/res/values-it/strings.xml | 20 ++ app/src/main/res/values-ja-rJP/strings.xml | 20 ++ app/src/main/res/values-pl-rPL/strings.xml | 20 ++ app/src/main/res/values-pt-rBR/strings.xml | 20 ++ app/src/main/res/values-pt-rPT/strings.xml | 20 ++ app/src/main/res/values-ru-rRU/strings.xml | 20 ++ app/src/main/res/values-sr/strings.xml | 20 ++ app/src/main/res/values-tr-rTR/strings.xml | 20 ++ app/src/main/res/values-uk-rUA/strings.xml | 20 ++ app/src/main/res/values/strings.xml | 20 ++ 46 files changed, 1200 insertions(+), 21 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/AutomodMessageDto.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ManageAutomodMessageRequestDto.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/AutomodMessage.kt create mode 100644 app/src/main/res/drawable/ic_automod_badge.png diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt index 587eb713b..138c046a2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt @@ -64,6 +64,7 @@ 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.AutomodMessage import com.flxrs.dankchat.data.twitch.message.ModerationMessage import com.flxrs.dankchat.data.twitch.message.NoticeMessage import com.flxrs.dankchat.data.twitch.message.PointRedemptionMessage @@ -190,6 +191,16 @@ class ChatAdapter( } is ModerationMessage -> holder.binding.itemText.handleModerationMessage(message, holder) + is AutomodMessage -> holder.binding.itemText.handleModerationMessage( + ModerationMessage( + channel = message.channel, + action = ModerationMessage.Action.Clear, + timestamp = message.timestamp, + id = message.id, + ), + holder, + ) + is PointRedemptionMessage -> holder.binding.itemText.handlePointRedemptionMessage(message, holder) is WhisperMessage -> holder.binding.itemText.handleWhisperMessage(message, holder) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 86d89423a..d5f157d3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -92,6 +92,8 @@ fun ChatComposable( onScrollDirectionChanged = onScrollDirectionChanged, scrollToMessageId = scrollToMessageId, onScrollToMessageHandled = onScrollToMessageHandled, + onAutomodAllow = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = true) }, + onAutomodDeny = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = false) }, recoveryFabTooltipState = recoveryFabTooltipState, onTourAdvance = onTourAdvance, onTourSkip = onTourSkip, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index e012c6d90..4366b54d6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -1,11 +1,16 @@ package com.flxrs.dankchat.chat.compose import android.content.Context +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.R +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState 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.repo.chat.ChatRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettings @@ -20,6 +25,7 @@ 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.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam import java.time.Instant @@ -41,6 +47,8 @@ import java.util.Locale class ChatComposeViewModel( @InjectedParam private val channel: UserName, private val repository: ChatRepository, + private val helixApiClient: HelixApiClient, + private val authDataStore: AuthDataStore, private val context: Context, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, @@ -119,4 +127,31 @@ class ChatComposeViewModel( result }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) + + fun manageAutomodMessage(heldMessageId: String, channel: UserName, allow: Boolean) { + viewModelScope.launch { + val userId = authDataStore.userIdString ?: return@launch + val action = if (allow) "ALLOW" else "DENY" + val actionVerb = context.getString(if (allow) R.string.automod_allow else R.string.automod_deny).lowercase() + + helixApiClient.manageAutomodMessage(userId, heldMessageId, action) + .onFailure { error -> + Log.e(TAG, "Failed to $action automod message $heldMessageId", error) + + val statusCode = (error as? HelixApiException)?.status?.value + val errorMessage = when (statusCode) { + 400 -> context.getString(R.string.automod_error_already_processed, actionVerb) + 401 -> context.getString(R.string.automod_error_not_authenticated, actionVerb) + 403 -> context.getString(R.string.automod_error_not_authorized, actionVerb) + 404 -> context.getString(R.string.automod_error_not_found, actionVerb) + else -> context.getString(R.string.automod_error_unknown, actionVerb) + } + repository.makeAndPostCustomSystemMessage(errorMessage, channel) + } + } + } + + companion object { + private val TAG = ChatComposeViewModel::class.java.simpleName + } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index c561df98d..698ae8c5d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -7,7 +7,9 @@ import com.flxrs.dankchat.chat.ChatImportance import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserName 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.Message import com.flxrs.dankchat.data.twitch.message.HighlightType import com.flxrs.dankchat.data.twitch.message.ModerationMessage import com.flxrs.dankchat.data.twitch.message.NoticeMessage @@ -38,14 +40,12 @@ object ChatMessageMapper { private val COLOR_REDEMPTION_HIGHLIGHT_LIGHT = Color(0xFF93F1FF) private val COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFC2F18D) private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFFFE087) - // Highlight colors - Dark theme private val COLOR_SUB_HIGHLIGHT_DARK = Color(0xFF543589) private val COLOR_MENTION_HIGHLIGHT_DARK = Color(0xFF773031) private val COLOR_REDEMPTION_HIGHLIGHT_DARK = Color(0xFF004F57) private val COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK = Color(0xFF2D5000) private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK = Color(0xFF574500) - // Checkered background colors private val CHECKERED_LIGHT = Color( android.graphics.Color.argb( @@ -109,6 +109,13 @@ object ChatMessageMapper { textAlpha = textAlpha ) + is AutomodMessage -> msg.toAutomodMessageUi( + tag = this.tag, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha + ) + is ModerationMessage -> msg.toModerationMessageUi( tag = this.tag, context = context, @@ -267,6 +274,51 @@ object ChatMessageMapper { ) } + private fun AutomodMessage.toAutomodMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + 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 + }, + ) + }, + userDisplayName = userName.formatWithDisplayName(userDisplayName), + rawNameColor = color, + messageText = messageText, + reason = reason, + status = uiStatus, + ) + } + private fun PrivMessage.toPrivMessageUi( tag: Int, context: Context, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 460dec171..a851f1c11 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -144,6 +144,30 @@ sealed interface ChatMessageUiState { val dateText: String, ) : ChatMessageUiState + /** + * AutoMod held messages with approve/deny actions + */ + @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, + val heldMessageId: String, + val channel: UserName, + val badges: List, + val userDisplayName: String, + val rawNameColor: Int, + val messageText: String, + val reason: String, + val status: AutomodMessageStatus, + ) : ChatMessageUiState { + enum class AutomodMessageStatus { Pending, Approved, Denied, Expired } + } + /** * Whisper messages */ @@ -179,6 +203,7 @@ data class BadgeUi( val url: String, val badge: Badge, val position: Int, // Position in message + val drawableResId: Int? = null, ) /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index e1ca4c1b5..2579384c6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.messages.AutomodMessageComposable import com.flxrs.dankchat.chat.compose.messages.DateSeparatorComposable import com.flxrs.dankchat.chat.compose.messages.ModerationMessageComposable import com.flxrs.dankchat.chat.compose.messages.NoticeMessageComposable @@ -96,6 +97,8 @@ fun ChatScreen( scrollToMessageId: String? = null, onScrollToMessageHandled: () -> Unit = {}, onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, + onAutomodAllow: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, + onAutomodDeny: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, containerColor: Color = MaterialTheme.colorScheme.background, showFabs: Boolean = true, recoveryFabTooltipState: TooltipState? = null, @@ -172,6 +175,7 @@ fun ChatScreen( 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" @@ -190,6 +194,8 @@ fun ChatScreen( onReplyClick = onReplyClick, onWhisperReply = onWhisperReply, onJumpToMessage = onJumpToMessage, + onAutomodAllow = onAutomodAllow, + onAutomodDeny = onAutomodDeny, ) // Add divider after each message if enabled @@ -318,6 +324,8 @@ private fun ChatMessageItem( onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit, onWhisperReply: ((userName: UserName) -> Unit)? = null, onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, + onAutomodAllow: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, + onAutomodDeny: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, ) { when (message) { is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( @@ -340,6 +348,13 @@ private fun ChatMessageItem( fontSize = fontSize ) + is ChatMessageUiState.AutomodMessageUi -> AutomodMessageComposable( + message = message, + fontSize = fontSize, + onAllow = onAutomodAllow, + onDeny = onAutomodDeny, + ) + is ChatMessageUiState.PrivMessageUi -> { if (onJumpToMessage != null) { val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt new file mode 100644 index 000000000..2cdd20007 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt @@ -0,0 +1,237 @@ +package com.flxrs.dankchat.chat.compose.messages + +import androidx.compose.foundation.layout.Column +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.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.draw.drawBehind +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.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus +import com.flxrs.dankchat.chat.compose.EmoteDimensions +import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent +import com.flxrs.dankchat.chat.compose.rememberNormalizedColor +import com.flxrs.dankchat.data.UserName + +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 backgroundColor = MaterialTheme.colorScheme.surfaceContainerHigh + 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 nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) + + // Resolve strings + val headerText = stringResource(R.string.automod_header, message.reason) + 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) + + // Header line: [badge] "AutoMod: Held a message for reason: {reason}. Allow will post it in chat. Allow Deny" + val headerString = remember(message, textColor, timestampColor, allowColor, denyColor, textSize, headerText, allowText, denyText, approvedText, deniedText, expiredText) { + 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) + append(" ") + } + } + + // Badges + message.badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") + } + + // "AutoMod: " in blue bold + withStyle(SpanStyle(color = AutoModBlue, fontWeight = FontWeight.Bold)) { + append("AutoMod: ") + } + + // Reason text + withStyle(SpanStyle(color = textColor)) { + append("$headerText ") + } + + // Allow / Deny buttons or status text + 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) { + 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) + append(" ") + } + } + + // Username in bold with user color + withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { + append("${message.userDisplayName}: ") + } + + // Message text + withStyle(SpanStyle(color = textColor)) { + append(message.messageText) + } + } + } + + // Badge inline content providers (same pattern as PrivMessage) + val badgeSize = EmoteScaling.getBadgeSize(fontSize) + val inlineContentProviders: Map Unit> = remember(message.badges, fontSize) { + buildMap { + message.badges.forEach { badge -> + put("BADGE_${badge.position}") { + coil3.compose.AsyncImage( + model = badge.drawableResId ?: badge.url, + contentDescription = badge.badge.type.name, + modifier = Modifier.size(badgeSize) + ) + } + } + } + } + + 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)) + } + } + } + + val resolvedAlpha = when (message.status) { + AutomodMessageStatus.Pending -> 1f + else -> 0.5f + } + + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .drawBehind { drawRect(backgroundColor) } + .alpha(resolvedAlpha) + .padding(vertical = 2.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 (no inline content needed) + TextWithMeasuredInlineContent( + text = bodyString, + inlineContentProviders = emptyMap(), + style = TextStyle(fontSize = textSize), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 7b67fd0d7..c474e4e27 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -252,11 +252,11 @@ private fun PrivMessageText( val badgeSize = EmoteScaling.getBadgeSize(fontSize) val inlineContentProviders: Map Unit> = remember(message.badges, message.emotes, fontSize) { buildMap Unit> { - // Badge providers + // Badge providers message.badges.forEach { badge -> put("BADGE_${badge.position}") { coil3.compose.AsyncImage( - model = badge.url, + model = badge.drawableResId ?: badge.url, contentDescription = badge.badge.type.name, modifier = Modifier.size(badgeSize) ) 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..4644cdc0d 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 @@ -7,6 +7,8 @@ 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.ChannelModerateDto import com.flxrs.dankchat.data.api.helix.HelixApiClient import com.flxrs.dankchat.di.DispatchersProvider @@ -116,6 +118,7 @@ class EventSubClient( val message = runCatching { json.decodeFromJsonElement(jsonObject) } .getOrElse { Log.e(TAG, "[EventSub] failed to parse message: $it") + Log.e(TAG, "[EventSub] raw JSON: $jsonObject") emitSystemMessage(message = "[EventSub] failed to parse message: $it") continue } @@ -259,15 +262,29 @@ 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( + val eventSubMessage = when (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, ) } - eventsChannel.trySend(message) + eventsChannel.trySend(eventSubMessage) } private fun handleRevocation(message: RevocationMessageDto) { 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 4f06e87d9..cc160c276 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 @@ -39,8 +39,9 @@ class EventSubManager( 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) + eventSubClient.subscribe(EventSubTopic.ChannelModerate(channel = it.name, broadcasterId = it.id, moderatorId = userId)) + eventSubClient.subscribe(EventSubTopic.AutomodMessageHold(channel = it.name, broadcasterId = it.id, moderatorId = userId)) + eventSubClient.subscribe(EventSubTopic.AutomodMessageUpdate(channel = it.name, broadcasterId = it.id, moderatorId = userId)) } } } @@ -69,10 +70,14 @@ class EventSubManager( } 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 + } + } + 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..662bf5df5 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,6 +1,8 @@ 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.ChannelModerateDto import kotlin.time.Instant @@ -14,3 +16,17 @@ 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 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..a33d89847 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 @@ -32,6 +32,48 @@ sealed interface EventSubTopic { 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 SubscribedTopic(val id: String, val topic: EventSubTopic) 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..4e6c69cee 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,9 @@ import kotlinx.serialization.Serializable enum class EventSubSubscriptionType { @SerialName("channel.moderate") ChannelModerate, + @SerialName("automod.message.hold") + AutomodMessageHold, + @SerialName("automod.message.update") + AutomodMessageUpdate, Unknown, } 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..90f8637d9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/AutomodMessageDto.kt @@ -0,0 +1,134 @@ +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 + +@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/helix/HelixApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApi.kt index 717783025..f836cac96 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 @@ -5,6 +5,7 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionRequestDto 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.ManageAutomodMessageRequestDto 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.MarkerRequestDto @@ -244,6 +245,13 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au contentType(ContentType.Application.Json) } + suspend fun postManageAutomodMessage(request: ManageAutomodMessageRequestDto): HttpResponse? = ktorClient.post("moderation/automod/message") { + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) 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 32e2dba24..ca7e20d59 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 @@ -8,6 +8,7 @@ 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.CheermoteSetDto +import com.flxrs.dankchat.data.api.helix.dto.ManageAutomodMessageRequestDto 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.CommercialDto @@ -229,6 +230,11 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { .data } + suspend fun manageAutomodMessage(userId: UserId, msgId: String, action: String): Result = runCatching { + helixApi.postManageAutomodMessage(ManageAutomodMessageRequestDto(userId = userId, msgId = msgId, action = action)) + .throwHelixApiErrorOnFailure() + } + suspend fun postShoutout(broadcastUserId: UserId, targetUserId: UserId, moderatorUserId: UserId): Result = runCatching { helixApi.postShoutout(broadcastUserId, targetUserId, moderatorUserId) .throwHelixApiErrorOnFailure() 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..49e3112a9 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 @@ -41,4 +41,6 @@ sealed interface HelixError { data object Forwarded : HelixError data object ShoutoutSelf : HelixError data object ShoutoutTargetNotStreaming : HelixError + data object MessageAlreadyProcessed : HelixError + data object MessageNotFound : HelixError } 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/repo/chat/ChatRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatRepository.kt index 8bf5afde4..3cc6d3336 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 @@ -7,9 +7,14 @@ 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.AutomodHeld +import com.flxrs.dankchat.data.api.eventapi.AutomodUpdate 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.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.api.recentmessages.RecentMessagesApiClient import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesApiException import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesError @@ -27,6 +32,9 @@ 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.badge.Badge +import com.flxrs.dankchat.data.twitch.badge.BadgeType +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 @@ -123,6 +131,7 @@ class ChatRepository( private var lastMessage = ConcurrentHashMap() private val loadedRecentsInChannels = mutableSetOf() private val knownRewards = ConcurrentHashMap() + private val knownAutomodHeldIds: MutableSet = ConcurrentHashMap.newKeySet() private val rewardMutex = Mutex() private val scrollBackLengthFlow = chatSettingsDataStore.debouncedScrollBack @@ -265,6 +274,45 @@ class ChatRepository( } } + is 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) ?: Message.DEFAULT_COLOR + 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, + ) + messages[eventMessage.channelName]?.update { current -> + current.addAndLimit(ChatItem(automodMsg, importance = ChatImportance.SYSTEM), scrollBackLength, ::onMessageRemoved) + } + } + + is 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 + } + updateAutomodMessageStatus(eventMessage.channelName, eventMessage.data.messageId, newStatus) + } + is SystemMessage -> makeAndPostSystemMessage(type = SystemMessageType.Custom(eventMessage.message)) } } @@ -647,9 +695,10 @@ class ChatRepository( return } - val rewardId = ircMessage.tags["custom-reward-id"] + val rewardId = ircMessage.tags["custom-reward-id"]?.takeIf { it.isNotEmpty() } + val isAutomodApproval = rewardId != null && knownAutomodHeldIds.remove(rewardId) val additionalMessages = when { - rewardId != null -> { + rewardId != null && !isAutomodApproval -> { val reward = rewardMutex.withLock { knownRewards[rewardId] ?.also { @@ -697,6 +746,9 @@ class ChatRepository( } if (message is PrivMessage) { + if (message.color != Message.DEFAULT_COLOR) { + usersRepository.cacheUserColor(message.name, message.color) + } if (message.name == authDataStore.userName) { val previousLastMessage = lastMessage[message.channel].orEmpty() val lastMessageWasCommand = previousLastMessage.startsWith('.') || previousLastMessage.startsWith('/') @@ -921,6 +973,39 @@ class ChatRepository( private fun onMessageRemoved(item: ChatItem) = repliesRepository.cleanupMessageThread(item.message) + private fun formatAutomodReason( + reason: String, + automod: AutomodReasonDto?, + blockedTerm: BlockedTermReasonDto?, + messageText: String, + ): String = when { + reason == "automod" && automod != null -> "${automod.category} (level ${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)}\"" + } + "blocked term: $terms" + } + + else -> reason + } + + 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 + } + } + } + } + companion object { private val TAG = ChatRepository::class.java.simpleName private val ESCAPE_TAG = 0x000E0002.codePointAsString 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..6e3659895 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,6 +13,7 @@ import java.util.concurrent.ConcurrentHashMap class UsersRepository { private val users = ConcurrentHashMap>() private val usersFlows = ConcurrentHashMap>>() + private val userColors = ConcurrentHashMap() fun getUsersFlow(channel: UserName): StateFlow> = usersFlows.getOrPut(channel) { MutableStateFlow(emptySet()) } fun findDisplayName(channel: UserName, userName: UserName): DisplayName? = users[channel]?.get(userName) @@ -49,6 +50,12 @@ class UsersRepository { usersFlows.remove(channel) } + fun cacheUserColor(userName: UserName, color: Int) { + userColors[userName] = color + } + + fun getCachedUserColor(userName: UserName): Int? = userColors[userName] + companion object { private const val USER_CACHE_SIZE = 5000 private val GLOBAL_CHANNEL_TAG = UserName("*") 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 d1d2df367..529b2bda7 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 @@ -692,6 +692,8 @@ class TwitchCommandRepository( } + HelixError.MessageAlreadyProcessed -> "The message has already been processed." + HelixError.MessageNotFound -> "The target message was not found." HelixError.Unknown -> GENERIC_ERROR_MESSAGE } } 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..8ec0aabc2 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/AutomodMessage.kt @@ -0,0 +1,23 @@ +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 + +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: String, + val badges: List = emptyList(), + val color: Int = Message.DEFAULT_COLOR, + val status: Status = Status.Pending, +) : Message() { + + enum class Status { Pending, Approved, Denied, Expired } +} 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..c60e36d5b 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 @@ -62,11 +62,16 @@ data class ModerationMessage( SharedTimeout, SharedUntimeout, SharedDelete, + AddBlockedTerm, + AddPermittedTerm, + RemoveBlockedTerm, + RemovePermittedTerm, } 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 val quotedTermsOrBlank get() = reason.takeUnless { it.isNullOrBlank() } ?: "terms" private fun getTrimmedReasonOrBlank(showDeletedMessage: Boolean): String { if (!showDeletedMessage) return "" @@ -138,7 +143,11 @@ data class ModerationMessage( 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 -> "$creatorUserDisplay deleted message from $targetUserDisplay in $sourceBroadcasterDisplay${getTrimmedReasonOrBlank(showDeletedMessage)}" + Action.AddBlockedTerm -> "$creatorUserDisplay added $quotedTermsOrBlank as a blocked term on AutoMod." + Action.AddPermittedTerm -> "$creatorUserDisplay added $quotedTermsOrBlank as a permitted term on AutoMod." + Action.RemoveBlockedTerm -> "$creatorUserDisplay removed $quotedTermsOrBlank as a blocked term on AutoMod." + Action.RemovePermittedTerm -> "$creatorUserDisplay removed $quotedTermsOrBlank as a permitted term on AutoMod." } } @@ -273,8 +282,12 @@ data class ModerationMessage( 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) { @@ -353,8 +366,12 @@ data class ModerationMessage( ChannelModerateAction.SharedChatUntimeout -> Action.SharedUntimeout ChannelModerateAction.SharedChatBan -> Action.SharedBan ChannelModerateAction.SharedChatUnban -> Action.SharedUnban - ChannelModerateAction.SharedChatDelete -> Action.SharedDelete - else -> error("Unexpected moderation action $this") + 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/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PrivMessage.kt index ce32ef652..a26149a41 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 @@ -95,7 +95,7 @@ val PrivMessage.isAnnouncement: Boolean get() = tags["msg-id"] == "announcement" val PrivMessage.isReward: Boolean - get() = tags["msg-id"] == "highlighted-message" || tags["custom-reward-id"] != null + get() = tags["msg-id"] == "highlighted-message" || !tags["custom-reward-id"].isNullOrEmpty() val PrivMessage.isFirstMessage: Boolean get() = tags["first-msg"] == "1" diff --git a/app/src/main/res/drawable/ic_automod_badge.png b/app/src/main/res/drawable/ic_automod_badge.png new file mode 100644 index 0000000000000000000000000000000000000000..e1f468b74f855e362885e6b0e203f1825f9a2631 GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh-3?!F4n4AKn6a#!hTp8A}GOTB1Sj+VP|NpnY z-X48(Y{{dg4c8hYPDUy2P-a-m%vry1BTzAONswP~e8Tf%tQ+-#+*D5&$B>F!$q7-c zj9ffX0oRn+6gC>=icMhIl(s}-c4Зразумела Прапусціць тур Тут вы можаце дадаць больш каналаў + + + Паведамленне затрымана з прычыны: %1$s. Дазвол апублікуе яго ў чаце. + Дазволіць + Адхіліць + Ухвалена + Адхілена + Тэрмін скончыўся + %1$s (узровень %2$d) + супадае з 1 заблакаваным тэрмінам %1$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. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index d0b54b91d..9eb5f23b9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -320,4 +320,24 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Entès Ometre el tour Aquí pots afegir més canals + + + S\'ha retingut un missatge per motiu: %1$s. Permetre el publicarà al xat. + Permetre + Denegar + Aprovat + Denegat + Caducat + %1$s (nivell %2$d) + coincideix amb 1 terme bloquejat %1$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. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a505eda5a..ed0c0af06 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -432,4 +432,24 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 + %1$s (úroveň %2$d) + odpovídá 1 blokovanému výrazu %1$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. diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c6e5f31c9..766cca5aa 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -447,4 +447,24 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 + %1$s (Stufe %2$d) + entspricht 1 blockiertem Begriff %1$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. diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 8971e6fa4..fdfc09992 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -257,4 +257,24 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + %1$s (level %2$d) + matches 1 blocked term %1$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. diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index bd26f8a4d..f6d715a84 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -258,4 +258,24 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + %1$s (level %2$d) + matches 1 blocked term %1$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. diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index f58648b63..ad547dc36 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -440,4 +440,24 @@ 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 + %1$s (level %2$d) + matches 1 blocked term %1$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. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index ec89a4b1e..2584d9f7b 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -446,4 +446,24 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 + %1$s (nivel %2$d) + coincide con 1 término bloqueado %1$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. diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 092735bed..78ffa59bc 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -284,4 +284,24 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Selvä Ohita esittely Voit lisätä lisää kanavia täältä + + + Viesti pidätetty syystä: %1$s. Salliminen julkaisee sen chatissa. + Salli + Hylkää + Hyväksytty + Hylätty + Vanhentunut + %1$s (taso %2$d) + vastaa 1 estettyä termiä %1$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. diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index bbaf266df..f041e3ced 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -430,4 +430,24 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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é + %1$s (niveau %2$d) + correspond à 1 terme bloqué %1$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. diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index df4b98e87..a49f0fda2 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -425,4 +425,24 @@ É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 + %1$s (%2$d. szint) + egyezik 1 blokkolt kifejezéssel: %1$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. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c76cc8f0f..ab1369002 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -414,4 +414,24 @@ 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 + %1$s (livello %2$d) + corrisponde a 1 termine bloccato %1$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. diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 136a72900..488e5f505 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -412,4 +412,24 @@ 了解 ツアーをスキップ ここでチャンネルを追加できます + + + 理由: %1$s でメッセージが保留されました。許可するとチャットに投稿されます。 + 許可 + 拒否 + 承認済み + 拒否済み + 期限切れ + %1$s (レベル %2$d) + 1件のブロックされた用語 %1$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を許可用語として削除しました。 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a789b2e0d..37a75b355 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -450,4 +450,24 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 + %1$s (poziom %2$d) + pasuje do 1 zablokowanego wyrażenia %1$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. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 871d7f933..1103c5ee7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -425,4 +425,24 @@ 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 + %1$s (nível %2$d) + corresponde a 1 termo bloqueado %1$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. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index fb2f5114d..ee1a6ede4 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -416,4 +416,24 @@ 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 + %1$s (nível %2$d) + corresponde a 1 termo bloqueado %1$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. diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 56b7a06ac..40f6aeb31 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -436,4 +436,24 @@ Понятно Пропустить тур Здесь можно добавить больше каналов + + + Сообщение задержано по причине: %1$s. Разрешение опубликует его в чате. + Разрешить + Отклонить + Одобрено + Отклонено + Истекло + %1$s (уровень %2$d) + совпадает с 1 заблокированным термином %1$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. diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index b6cc204a8..db6dcce3a 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -225,4 +225,24 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Разумем Прескочи обилазак Овде можете додати више канала + + + Порука задржана из разлога: %1$s. Дозвола ће је објавити у ћаскању. + Дозволи + Одбиј + Одобрено + Одбијено + Истекло + %1$s (ниво %2$d) + подудара се са 1 блокираним термином %1$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. diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 62711c831..4cd214977 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -446,4 +446,24 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 + %1$s (seviye %2$d) + 1 engellenen terimle eşleşiyor %1$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ı. diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index db2a5dd13..a05f9a0ae 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -433,4 +433,24 @@ Зрозуміло Пропустити тур Тут ви можете додати більше каналів + + + Повідомлення затримано з причини: %1$s. Дозвіл опублікує його в чаті. + Дозволити + Відхилити + Схвалено + Відхилено + Термін минув + %1$s (рівень %2$d) + збігається з 1 заблокованим терміном %1$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. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44892afca..7626b3c34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,6 +89,26 @@ %1$s added 7TV Emote %2$s. %1$s renamed 7TV Emote %2$s to %3$s. %1$s removed 7TV Emote %2$s. + + Held a message for reason: %1$s. Allow will post it in chat. + Allow + Deny + Approved + Denied + Expired + %1$s (level %2$d) + matches 1 blocked term %1$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. + < Message deleted > Regex Add an entry From f100500e766f5a8f9eef9eb3b1f78114fb58d7f0 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 15 Mar 2026 17:23:59 +0100 Subject: [PATCH 042/349] refactor(compose): Color usernames in UserNotice messages --- .../chat/compose/ChatComposeViewModel.kt | 5 +- .../chat/compose/ChatMessageMapper.kt | 43 ++++-- .../chat/compose/ChatMessageUiState.kt | 3 + .../chat/compose/messages/SystemMessages.kt | 129 ++++++++++++++++-- .../compose/MessageHistoryComposeViewModel.kt | 6 +- .../compose/MentionComposeViewModel.kt | 11 +- .../compose/RepliesComposeViewModel.kt | 8 +- .../data/repo/chat/UsersRepository.kt | 7 +- 8 files changed, 175 insertions(+), 37 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 4366b54d6..5486cd885 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.R import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.HelixApiClient import com.flxrs.dankchat.data.api.helix.HelixApiException @@ -47,6 +46,7 @@ import java.util.Locale class ChatComposeViewModel( @InjectedParam private val channel: UserName, private val repository: ChatRepository, + private val chatMessageMapper: ChatMessageMapper, private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore, private val context: Context, @@ -92,7 +92,8 @@ class ChatComposeViewModel( val cacheKey = "${item.message.id}-${item.tag}-$altBg" val mapped = mappingCache.getOrPut(cacheKey) { - item.toChatMessageUiState( + chatMessageMapper.mapToUiState( + item = item, context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 698ae8c5d..7ed5985d4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -25,14 +25,20 @@ import com.flxrs.dankchat.data.twitch.message.senderAliasOrFormattedName import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettings import com.flxrs.dankchat.preferences.chat.ChatSettings +import com.flxrs.dankchat.data.repo.chat.UsersRepository +import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.utils.DateTimeUtils import com.google.android.material.color.MaterialColors +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. */ -object ChatMessageMapper { +@Single +class ChatMessageMapper( + private val usersRepository: UsersRepository, +) { // Highlight colors - Light theme private val COLOR_SUB_HIGHLIGHT_LIGHT = Color(0xFFD1C4E9) @@ -60,22 +66,23 @@ object ChatMessageMapper { ) ) - fun ChatItem.toChatMessageUiState( + fun mapToUiState( + item: ChatItem, context: Context, appearanceSettings: AppearanceSettings, chatSettings: ChatSettings, preferenceStore: DankChatPreferenceStore, isAlternateBackground: Boolean, ): ChatMessageUiState { - val textAlpha = when (importance) { + val textAlpha = when (item.importance) { ChatImportance.SYSTEM -> 1f ChatImportance.DELETED -> 0.5f ChatImportance.REGULAR -> 1f } - return when (val msg = message) { + return when (val msg = item.message) { is SystemMessage -> msg.toSystemMessageUi( - tag = this.tag, + tag = item.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -83,7 +90,7 @@ object ChatMessageMapper { ) is NoticeMessage -> msg.toNoticeMessageUi( - tag = this.tag, + tag = item.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -91,7 +98,7 @@ object ChatMessageMapper { ) is UserNoticeMessage -> msg.toUserNoticeMessageUi( - tag = this.tag, + tag = item.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -99,25 +106,25 @@ object ChatMessageMapper { ) is PrivMessage -> msg.toPrivMessageUi( - tag = this.tag, + tag = item.tag, context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, - isMentionTab = isMentionTab, - isInReplies = isInReplies, + isMentionTab = item.isMentionTab, + isInReplies = item.isInReplies, textAlpha = textAlpha ) is AutomodMessage -> msg.toAutomodMessageUi( - tag = this.tag, + tag = item.tag, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, textAlpha = textAlpha ) is ModerationMessage -> msg.toModerationMessageUi( - tag = this.tag, + tag = item.tag, context = context, chatSettings = chatSettings, preferenceStore = preferenceStore, @@ -126,14 +133,14 @@ object ChatMessageMapper { ) is PointRedemptionMessage -> msg.toPointRedemptionMessageUi( - tag = this.tag, + tag = item.tag, context = context, chatSettings = chatSettings, textAlpha = textAlpha ) is WhisperMessage -> msg.toWhisperMessageUi( - tag = this.tag, + tag = item.tag, context = context, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, @@ -238,6 +245,12 @@ object ChatMessageMapper { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) } else "" + val displayName = tags["display-name"].orEmpty() + val login = tags["login"]?.toUserName() + val rawNameColor = tags["color"]?.ifBlank { null }?.let(android.graphics.Color::parseColor) + ?: login?.let { usersRepository.getCachedUserColor(it) } + ?: Message.DEFAULT_COLOR + return ChatMessageUiState.UserNoticeMessageUi( id = id, tag = tag, @@ -246,6 +259,8 @@ object ChatMessageMapper { darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, message = message, + displayName = displayName, + rawNameColor = rawNameColor, shouldHighlight = shouldHighlight ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index a851f1c11..cfddb3db7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -7,6 +7,7 @@ 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.data.twitch.message.MessageThreadHeader /** @@ -92,6 +93,8 @@ sealed interface ChatMessageUiState { override val textAlpha: Float, override val enableRipple: Boolean = false, val message: String, + val displayName: String = "", + val rawNameColor: Int = Message.DEFAULT_COLOR, val shouldHighlight: Boolean, ) : ChatMessageUiState diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index cef12af6e..58639a141 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -1,10 +1,34 @@ package com.flxrs.dankchat.chat.compose.messages +import android.util.Log +import androidx.browser.customtabs.CustomTabsIntent +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.foundation.text.ClickableText +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.platform.LocalContext +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 androidx.core.net.toUri import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.messages.common.SimpleMessageContainer +import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.chat.compose.rememberNormalizedColor /** * Renders a system message (connected, disconnected, emote loading failures, etc.) @@ -48,22 +72,109 @@ fun NoticeMessageComposable( /** * Renders a user notice message (subscriptions, announcements, etc.) + * The display name is highlighted with the user's color. */ +@Suppress("DEPRECATION") @Composable fun UserNoticeMessageComposable( message: ChatMessageUiState.UserNoticeMessageUi, 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, - ) + val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val textColor = MaterialTheme.colorScheme.onSurface + val linkColor = MaterialTheme.colorScheme.primary + val timestampColor = MaterialTheme.colorScheme.onSurface + val nameColor = rememberNormalizedColor(message.rawNameColor, bgColor) + val textSize = fontSize.sp + val context = LocalContext.current + + val annotatedString = remember(message, textColor, nameColor, linkColor, timestampColor, textSize) { + 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) + } + append(" ") + } + + // 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, fontWeight = FontWeight.Bold)) { + 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() + .background(bgColor) + .padding(vertical = 2.dp) + .alpha(message.textAlpha) + ) { + ClickableText( + text = annotatedString, + style = TextStyle(fontSize = textSize), + modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + annotatedString.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, annotation.item.toUri()) + } catch (e: Exception) { + Log.e("UserNoticeMessage", "Error launching URL", e) + } + } + } + ) + } } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt index 7d4f72e1d..8be382a21 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageMapper import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.search.ChatItemFilter import com.flxrs.dankchat.chat.search.ChatSearchFilter @@ -44,6 +44,7 @@ class MessageHistoryComposeViewModel( @InjectedParam private val channel: UserName, chatRepository: ChatRepository, usersRepository: UsersRepository, + private val chatMessageMapper: ChatMessageMapper, private val context: Context, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, @@ -72,7 +73,8 @@ class MessageHistoryComposeViewModel( .filter { ChatItemFilter.matches(it, activeFilters) } .mapIndexed { index, item -> val altBg = index.isEven && appearanceSettings.checkeredMessages - item.toChatMessageUiState( + chatMessageMapper.mapToUiState( + item = item, context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt index 60858f734..b2b1f8a5f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageMapper import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore @@ -26,6 +26,7 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class MentionComposeViewModel( chatRepository: ChatRepository, + private val chatMessageMapper: ChatMessageMapper, private val context: Context, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, @@ -38,7 +39,7 @@ class MentionComposeViewModel( fun setCurrentTab(index: Int) { _currentTab.value = index } - + val mentions: StateFlow> = chatRepository.mentions .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), emptyList()) val whispers: StateFlow> = chatRepository.whispers @@ -51,7 +52,8 @@ class MentionComposeViewModel( ) { messages, appearanceSettings, chatSettings -> messages.mapIndexed { index, item -> val altBg = index.isEven && appearanceSettings.checkeredMessages - item.toChatMessageUiState( + chatMessageMapper.mapToUiState( + item = item, context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, @@ -68,7 +70,8 @@ class MentionComposeViewModel( ) { messages, appearanceSettings, chatSettings -> messages.mapIndexed { index, item -> val altBg = index.isEven && appearanceSettings.checkeredMessages - item.toChatMessageUiState( + chatMessageMapper.mapToUiState( + item = item, context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt index 8cb001397..2c485fdd3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt @@ -3,7 +3,7 @@ package com.flxrs.dankchat.chat.replies.compose import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.compose.ChatMessageMapper.toChatMessageUiState +import com.flxrs.dankchat.chat.compose.ChatMessageMapper import com.flxrs.dankchat.chat.replies.RepliesState import com.flxrs.dankchat.chat.replies.RepliesUiState import com.flxrs.dankchat.data.repo.RepliesRepository @@ -26,6 +26,7 @@ import kotlin.time.Duration.Companion.seconds class RepliesComposeViewModel( @InjectedParam private val rootMessageId: String, repliesRepository: RepliesRepository, + private val chatMessageMapper: ChatMessageMapper, private val context: Context, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, @@ -40,7 +41,7 @@ class RepliesComposeViewModel( } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(emptyList())) - + val uiState: StateFlow = combine( state, appearanceSettingsDataStore.settings, @@ -51,7 +52,8 @@ class RepliesComposeViewModel( is RepliesState.Found -> { val uiMessages = repliesState.items.mapIndexed { index, item -> val altBg = index.isEven && appearanceSettings.checkeredMessages - item.toChatMessageUiState( + chatMessageMapper.mapToUiState( + item = item, context = context, appearanceSettings = appearanceSettings, chatSettings = chatSettings, 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 6e3659895..bd55c7cad 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,7 +13,7 @@ import java.util.concurrent.ConcurrentHashMap class UsersRepository { private val users = ConcurrentHashMap>() private val usersFlows = ConcurrentHashMap>>() - private val userColors = 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) @@ -51,13 +51,14 @@ class UsersRepository { } fun cacheUserColor(userName: UserName, color: Int) { - userColors[userName] = color + userColors.put(userName, color) } - fun getCachedUserColor(userName: UserName): Int? = userColors[userName] + 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("*") } } From 92bc6dfb858b79b33040465907b0ddcf11a7485d Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 15 Mar 2026 17:50:16 +0100 Subject: [PATCH 043/349] feat(compose): Add character counter and clear button to chat input --- .../dankchat/main/compose/ChatBottomBar.kt | 2 ++ .../dankchat/main/compose/ChatInputLayout.kt | 29 +++++++++++++------ .../flxrs/dankchat/main/compose/MainScreen.kt | 4 +++ .../main/compose/MainScreenDialogs.kt | 2 +- .../appearance/AppearanceSettings.kt | 1 + .../appearance/AppearanceSettingsDataStore.kt | 3 ++ .../appearance/AppearanceSettingsScreen.kt | 9 ++++++ .../appearance/AppearanceSettingsViewModel.kt | 4 ++- app/src/main/res/values-be-rBY/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de-rDE/strings.xml | 2 ++ app/src/main/res/values-en-rAU/strings.xml | 2 ++ app/src/main/res/values-en-rGB/strings.xml | 2 ++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-fi-rFI/strings.xml | 2 ++ app/src/main/res/values-fr-rFR/strings.xml | 2 ++ app/src/main/res/values-hu-rHU/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja-rJP/strings.xml | 2 ++ app/src/main/res/values-pl-rPL/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt-rPT/strings.xml | 2 ++ app/src/main/res/values-ru-rRU/strings.xml | 2 ++ app/src/main/res/values-sr/strings.xml | 2 ++ app/src/main/res/values-tr-rTR/strings.xml | 2 ++ app/src/main/res/values-uk-rUA/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 29 files changed, 85 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index 159d8de63..14ab564a0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -38,6 +38,7 @@ fun ChatBottomBar( hasStreamData: Boolean, isSheetOpen: Boolean, inputActions: List, + showCharacterCounter: Boolean = false, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -79,6 +80,7 @@ fun ChatBottomBar( isStreamActive = isStreamActive, hasStreamData = hasStreamData, inputActions = inputActions, + showCharacterCounter = showCharacterCounter, onSend = onSend, onLastMessageClick = onLastMessageClick, onEmoteClick = onEmoteClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 1b7c574b8..0ac2c6121 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -120,6 +120,8 @@ data class TourOverlayState( val onSkip: (() -> Unit)? = null, ) +private const val TWITCH_MESSAGE_CODE_POINT_LIMIT = 500 + @Composable fun ChatInputLayout( textFieldState: TextFieldState, @@ -137,6 +139,7 @@ fun ChatInputLayout( isStreamActive: Boolean, hasStreamData: Boolean, inputActions: List, + showCharacterCounter: Boolean = false, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -156,7 +159,6 @@ fun ChatInputLayout( modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } - var maxTextFieldHeight by remember { mutableIntStateOf(0) } val hint = when (inputState) { InputState.Default -> stringResource(R.string.hint_connected) InputState.Replying -> stringResource(R.string.hint_replying) @@ -295,16 +297,25 @@ fun ChatInputLayout( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - val height = maxOf(placeable.height, maxTextFieldHeight) - maxTextFieldHeight = height - layout(placeable.width, height) { - placeable.placeRelative(0, 0) - } - } .padding(bottom = 0.dp), // Reduce bottom padding as actions are below label = { Text(hint) }, + suffix = if (showCharacterCounter) { + { + val text = textFieldState.text.toString() + val codePointCount = text.codePointCount(0, text.length) + val isOverLimit = codePointCount > TWITCH_MESSAGE_CODE_POINT_LIMIT + Text( + text = "$codePointCount/$TWITCH_MESSAGE_CODE_POINT_LIMIT", + color = when { + isOverLimit -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } else { + null + }, colors = textFieldColors, shape = RoundedCornerShape(0.dp), lineLimits = TextFieldLineLimits.MultiLine( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 87c6de126..eb2ae2ad3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -430,6 +430,9 @@ fun MainScreen( val inputActions by appearanceSettingsDataStore.inputActions.collectAsStateWithLifecycle( initialValue = appearanceSettingsDataStore.current().inputActions ) + val showCharacterCounter by appearanceSettingsDataStore.showCharacterCounter.collectAsStateWithLifecycle( + initialValue = appearanceSettingsDataStore.current().showCharacterCounter + ) val gestureInputHidden by mainScreenViewModel.gestureInputHidden.collectAsStateWithLifecycle() val gestureToolbarHidden by mainScreenViewModel.gestureToolbarHidden.collectAsStateWithLifecycle() val effectiveShowInput = showInputState && !gestureInputHidden @@ -579,6 +582,7 @@ fun MainScreen( hasStreamData = hasStreamData, isSheetOpen = isSheetOpen, inputActions = inputActions, + showCharacterCounter = showCharacterCounter, onSend = chatInputViewModel::sendMessage, onLastMessageClick = chatInputViewModel::getLastMessage, onEmoteClick = { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index fd50ffc2a..afb54b8c7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -219,7 +219,7 @@ fun MainScreenDialogs( }, onCopy = { scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", params.fullMessage))) + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", s.originalMessage))) snackbarHostState.showSnackbar(context.getString(R.string.snackbar_message_copied)) } }, 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 1528995a6..fdd568c11 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 @@ -19,6 +19,7 @@ data class AppearanceSettings( val autoDisableInput: Boolean = true, val showChips: Boolean = true, val showChangelogs: Boolean = true, + val showCharacterCounter: Boolean = false, val inputActions: List = listOf( InputAction.Stream, InputAction.RoomState, InputAction.Search, InputAction.LastMessage, 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 f0c9d76ad..013689136 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 @@ -89,6 +89,9 @@ class AppearanceSettingsDataStore( val inputActions = settings .map { it.inputActions } .distinctUntilChanged() + val showCharacterCounter = settings + .map { it.showCharacterCounter } + .distinctUntilChanged() fun current() = currentSettings.value 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 index 28b903312..33731282c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -45,6 +45,7 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.C 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.ShowChips import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.ShowInput import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.Theme @@ -125,6 +126,7 @@ private fun AppearanceSettingsContent( showInput = settings.showInput, autoDisableInput = settings.autoDisableInput, showChips = settings.showChips, + showCharacterCounter = settings.showCharacterCounter, showInputSetting = !useComposeUi, showChipsSetting = !useComposeUi, onInteraction = onInteraction, @@ -139,6 +141,7 @@ private fun ComponentsCategory( showInput: Boolean, autoDisableInput: Boolean, showChips: Boolean, + showCharacterCounter: Boolean, showInputSetting: Boolean, showChipsSetting: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit, @@ -168,6 +171,12 @@ private fun ComponentsCategory( onClick = { onInteraction(ShowChips(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)) }, + ) } } 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 e85ff8ae5..048ec60fe 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 @@ -39,7 +39,8 @@ class AppearanceSettingsViewModel( 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.ShowChangelogs -> dataStore.update { it.copy(showChangelogs = interaction.value) } + is AppearanceSettingsInteraction.ShowCharacterCounter -> dataStore.update { it.copy(showCharacterCounter = interaction.value) } } } } @@ -58,6 +59,7 @@ sealed interface AppearanceSettingsInteraction { data class AutoDisableInput(val value: Boolean) : AppearanceSettingsInteraction data class ShowChips(val value: Boolean) : AppearanceSettingsInteraction data class ShowChangelogs(val value: Boolean) : AppearanceSettingsInteraction + data class ShowCharacterCounter(val value: Boolean) : AppearanceSettingsInteraction } data class AppearanceSettingsUiState( diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 61df44426..e7dd08f81 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -252,6 +252,8 @@ Умовы карыстання і палітыка карыстальніка Twitch: Адлюстроўваць хуткія пераключальнікі Адлюстроўваць пераключальнікі для поўнаэкраннага рэжыму і налады рэжымаў чату + Паказваць лічыльнік сімвалаў + Адлюстроўвае колькасць кодавых пунктаў у полі ўводу Загрузчык медыя Наладзіць загрузчык Нядаўнія загрузкі diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9eb5f23b9..8d4d2601c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -227,6 +227,8 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 Pujador de media Configurar pujador Pujades recents diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ed0c0af06..9c13e3a0f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -259,6 +259,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 Nahrávač médií Konfigurace nahrávače Nedávná nahrání diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 766cca5aa..4caac01c9 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -259,6 +259,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 Medienupload Upload konfigurieren Letzte Uploads diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index fdfc09992..92d6333cf 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -218,6 +218,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 Media uploader Configure uploader Recent uploads diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index f6d715a84..1ff6c0e39 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -218,6 +218,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 Media uploader Configure uploader Recent uploads diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ad547dc36..a1f73df7f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -252,6 +252,8 @@ 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 Media uploader Configure uploader Recent uploads diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 2584d9f7b..01079297e 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -258,6 +258,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 Subidor de multimedia Configurar subidor Subidas recientes diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 78ffa59bc..651cbed3a 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -227,6 +227,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Tavallinen painallus maininnat ja pitkä painallus popup-ikkuna Aseta kieli englanniksi Näkyvät kolmannen osapuolen hymiöt + Näytä merkkimäärälaskuri + Näyttää koodipisteiden määrän syöttökentässä Median lähettäjä Työkalut Teema diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index f041e3ced..450aa4a5f 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -257,6 +257,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 Mise en ligne de fichiers Configurer la mise en ligne de fichiers personnalisé Fichiers récents diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index a49f0fda2..80e172688 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -252,6 +252,8 @@ 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 Média feltöltő Feltöltő konfigurálása Legutóbbi feltőltések diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ab1369002..3fd15e1a6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -251,6 +251,8 @@ 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 Caricatore multimediale Configura caricatore Caricamenti recenti diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 488e5f505..9b695df44 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -251,6 +251,8 @@ Twitch利用規約とユーザーポリシー: チップの動作を表示 全画面表示、ストリーム、チャットモードの調整のためのチップを表示します + 文字数カウンターを表示 + 入力フィールドにコードポイント数を表示します メディアアップローダー アップローダーを設定 最近のアップロード diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 37a75b355..0205037a7 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -256,6 +256,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 Przesyłacz media Przesyłacz konfiguracji Ostatnio udostępnione diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1103c5ee7..db7c484ad 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -252,6 +252,8 @@ 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 Carregador de mídia Configurar carregador Envios recentes diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index ee1a6ede4..4a30133fc 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -252,6 +252,8 @@ 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 Carregador de mídia Configurar carregador Carregamentos recentes diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 40f6aeb31..7140a7e36 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -257,6 +257,8 @@ Условия пользования и политика пользователя Twitch: Отображать быстрые переключатели Отображать переключатели для полноэкранного режима, трансляций и настройки режимов чата + Показывать счётчик символов + Отображает количество кодовых точек в поле ввода Загрузчик медиа Настроить загрузчик Недавние загрузки diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index db6dcce3a..bda947fd2 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -197,6 +197,8 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Forsiraj engleski jezik Forisraj čitanje teksta na engleskom umesto na podrazumevanom jeziku Vidljivost emotova nezavisnih servisa + Prikaži brojač karaktera + Prikazuje broj kodnih tačaka u polju za unos Prikaži/sakrij traku aplikacije Greška: %s Odjaviti se? diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 4cd214977..23ebd70fb 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -258,6 +258,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 Medya yükleyici Yükleyiciyi ayarla Son yüklemeler diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index a05f9a0ae..b04728b19 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -259,6 +259,8 @@ Умови обслуговування Twitch Показувати плаваючі кнопки Показує кнопки для перемикання повноекранного режиму та плеєру і змінення режимів чату + Показувати лічильник символів + Відображає кількість кодових точок у полі введення Сервіс завантаження медіа Налаштувати сервіс завантаження медіа Останні завантаження diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7626b3c34..4c7ee3934 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -343,6 +343,8 @@ 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 Media uploader Configure uploader Recent uploads From e0c5f45f99fd4e39190300da8611231b862cb61e Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 15 Mar 2026 18:30:58 +0100 Subject: [PATCH 044/349] refactor(compose): Fix architecture violations and consolidate ViewModel states --- .../dankchat/chat/compose/ChatComposable.kt | 15 ++-- .../chat/compose/ChatComposeViewModel.kt | 20 ++++++ .../compose/MessageHistoryComposeViewModel.kt | 12 ++++ .../chat/mention/compose/MentionComposable.kt | 15 ++-- .../compose/MentionComposeViewModel.kt | 13 ++++ .../chat/replies/compose/RepliesComposable.kt | 15 ++-- .../compose/RepliesComposeViewModel.kt | 12 ++++ .../dankchat/main/compose/ChannelTabRow.kt | 3 +- .../dankchat/main/compose/ChatBottomBar.kt | 9 +-- .../dankchat/main/compose/ChatInputLayout.kt | 71 ++++++++++++------- .../main/compose/ChatInputViewModel.kt | 26 +++++-- .../main/compose/FullScreenSheetOverlay.kt | 10 ++- .../flxrs/dankchat/main/compose/MainScreen.kt | 62 ++++++---------- .../main/compose/MainScreenViewModel.kt | 62 +++++++++++++--- .../main/compose/SheetNavigationViewModel.kt | 18 ++++- .../flxrs/dankchat/main/compose/StreamView.kt | 2 +- .../dankchat/main/compose/StreamViewModel.kt | 31 +++++--- .../main/compose/SuggestionDropdown.kt | 3 +- .../main/compose/sheets/MentionSheet.kt | 3 - .../compose/sheets/MessageHistorySheet.kt | 20 ++---- .../main/compose/sheets/RepliesSheet.kt | 3 - .../appearance/AppearanceSettingsScreen.kt | 2 + .../appearance/AppearanceSettingsViewModel.kt | 2 + .../preferences/chat/ChatSettingsViewModel.kt | 2 + 24 files changed, 279 insertions(+), 152 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index d5f157d3a..52e27a852 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -13,9 +13,6 @@ import coil3.compose.LocalPlatformContext import coil3.imageLoader import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -59,12 +56,8 @@ fun ChatComposable( parameters = { parametersOf(channel) } ) - val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() - val chatSettingsDataStore: ChatSettingsDataStore = koinInject() - val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) - val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) - val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) + val displaySettings by viewModel.chatDisplaySettings.collectAsStateWithLifecycle() // Create singleton coordinator using the app's ImageLoader (with disk cache, AnimatedImageDecoder, etc.) val context = LocalPlatformContext.current @@ -73,9 +66,9 @@ fun ChatComposable( CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, - fontSize = appearanceSettings.fontSize.toFloat(), - showLineSeparator = appearanceSettings.lineSeparator, - animateGifs = chatSettings.animateGifs, + fontSize = displaySettings.fontSize, + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, modifier = modifier.fillMaxSize(), onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 5486cd885..e95678708 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -18,11 +18,13 @@ import com.flxrs.dankchat.preferences.chat.ChatSettings import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils import com.flxrs.dankchat.utils.extensions.isEven +import androidx.compose.runtime.Immutable import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @@ -55,6 +57,17 @@ class ChatComposeViewModel( private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { + val chatDisplaySettings: StateFlow = combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + private val chat: StateFlow> = repository .getChat(channel) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) @@ -156,3 +169,10 @@ class ChatComposeViewModel( private val TAG = ChatComposeViewModel::class.java.simpleName } } + +@Immutable +data class ChatDisplaySettings( + val fontSize: Float = 14f, + val showLineSeparator: Boolean = false, + val animateGifs: Boolean = true, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt index 8be382a21..4bc1bbc18 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.compose.ChatDisplaySettings import com.flxrs.dankchat.chat.compose.ChatMessageMapper import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.search.ChatItemFilter @@ -51,6 +52,17 @@ class MessageHistoryComposeViewModel( private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { + val chatDisplaySettings: StateFlow = combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + val searchFieldState = TextFieldState() private val searchQuery = snapshotFlow { searchFieldState.text.toString() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index bac0daa9d..9a87f6bef 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -15,9 +15,7 @@ import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import androidx.compose.ui.graphics.Color -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import org.koin.compose.koinInject +import com.flxrs.dankchat.chat.compose.ChatDisplaySettings /** * Standalone composable for mentions/whispers display. @@ -31,7 +29,6 @@ import org.koin.compose.koinInject @Composable fun MentionComposable( mentionViewModel: MentionComposeViewModel, - appearanceSettingsDataStore: AppearanceSettingsDataStore, isWhisperTab: Boolean, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -42,9 +39,7 @@ fun MentionComposable( contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { - val chatSettingsDataStore: ChatSettingsDataStore = koinInject() - val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) - val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) + val displaySettings by mentionViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val messages by when { isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) @@ -56,9 +51,9 @@ fun MentionComposable( CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, - fontSize = appearanceSettings.fontSize.toFloat(), - showLineSeparator = appearanceSettings.lineSeparator, - animateGifs = chatSettings.animateGifs, + fontSize = displaySettings.fontSize, + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, showChannelPrefix = !isWhisperTab, modifier = modifier, onUserClick = onUserClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt index b2b1f8a5f..1aeb67f0e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt @@ -3,7 +3,9 @@ package com.flxrs.dankchat.chat.mention.compose import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.chat.compose.ChatDisplaySettings import com.flxrs.dankchat.chat.compose.ChatMessageMapper import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.data.repo.chat.ChatRepository @@ -33,6 +35,17 @@ class MentionComposeViewModel( private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { + val chatDisplaySettings: StateFlow = combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + private val _currentTab = MutableStateFlow(0) val currentTab: StateFlow = _currentTab diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index a466833ea..56a04499a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -15,9 +15,7 @@ import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.replies.RepliesUiState import androidx.compose.ui.graphics.Color -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import org.koin.compose.koinInject +import com.flxrs.dankchat.chat.compose.ChatDisplaySettings /** * Standalone composable for reply thread display. @@ -32,7 +30,6 @@ import org.koin.compose.koinInject @Composable fun RepliesComposable( repliesViewModel: RepliesComposeViewModel, - appearanceSettingsDataStore: AppearanceSettingsDataStore, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onNotFound: () -> Unit, @@ -40,9 +37,7 @@ fun RepliesComposable( contentPadding: PaddingValues = PaddingValues(), modifier: Modifier = Modifier ) { - val chatSettingsDataStore: ChatSettingsDataStore = koinInject() - val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = appearanceSettingsDataStore.current()) - val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) + val displaySettings by repliesViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) val context = LocalPlatformContext.current @@ -53,9 +48,9 @@ fun RepliesComposable( is RepliesUiState.Found -> { ChatScreen( messages = (uiState as RepliesUiState.Found).items, - fontSize = appearanceSettings.fontSize.toFloat(), - showLineSeparator = appearanceSettings.lineSeparator, - animateGifs = chatSettings.animateGifs, + fontSize = displaySettings.fontSize, + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, modifier = modifier, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt index 2c485fdd3..c06bb651e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt @@ -3,6 +3,7 @@ package com.flxrs.dankchat.chat.replies.compose import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.chat.compose.ChatDisplaySettings import com.flxrs.dankchat.chat.compose.ChatMessageMapper import com.flxrs.dankchat.chat.replies.RepliesState import com.flxrs.dankchat.chat.replies.RepliesUiState @@ -33,6 +34,17 @@ class RepliesComposeViewModel( private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { + val chatDisplaySettings: StateFlow = combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + val state = repliesRepository.getThreadItemsFlow(rootMessageId) .map { when { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt index ce34da923..6f372f6fc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt @@ -6,12 +6,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ChannelTabRow( - tabs: List, + tabs: ImmutableList, selectedIndex: Int, onTabSelected: (Int) -> Unit ) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index 14ab564a0..3d94fbff4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.flxrs.dankchat.preferences.appearance.InputAction import com.flxrs.dankchat.utils.compose.rememberRoundedCornerHorizontalPadding +import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -37,8 +38,8 @@ fun ChatBottomBar( isStreamActive: Boolean, hasStreamData: Boolean, isSheetOpen: Boolean, - inputActions: List, - showCharacterCounter: Boolean = false, + inputActions: ImmutableList, + characterCounter: CharacterCounterState = CharacterCounterState.Hidden, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -50,7 +51,7 @@ fun ChatBottomBar( onChangeRoomState: () -> Unit, onSearchClick: () -> Unit, onNewWhisper: (() -> Unit)?, - onInputActionsChanged: (List) -> Unit, + onInputActionsChanged: (ImmutableList) -> Unit, onInputHeightChanged: (Int) -> Unit, instantHide: Boolean = false, tourState: TourOverlayState = TourOverlayState(), @@ -80,7 +81,7 @@ fun ChatBottomBar( isStreamActive = isStreamActive, hasStreamData = hasStreamData, inputActions = inputActions, - showCharacterCounter = showCharacterCounter, + characterCounter = characterCounter, onSend = onSend, onLastMessageClick = onLastMessageClick, onEmoteClick = onEmoteClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 0ac2c6121..bb5e5de7f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -5,9 +5,11 @@ import androidx.compose.runtime.Immutable import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween +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.foundation.clickable @@ -29,8 +31,10 @@ 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.Clear import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.EmojiEmotions @@ -104,6 +108,8 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState 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 @@ -120,7 +126,6 @@ data class TourOverlayState( val onSkip: (() -> Unit)? = null, ) -private const val TWITCH_MESSAGE_CODE_POINT_LIMIT = 500 @Composable fun ChatInputLayout( @@ -138,8 +143,8 @@ fun ChatInputLayout( isModerator: Boolean, isStreamActive: Boolean, hasStreamData: Boolean, - inputActions: List, - showCharacterCounter: Boolean = false, + inputActions: ImmutableList, + characterCounter: CharacterCounterState = CharacterCounterState.Hidden, onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, @@ -151,7 +156,7 @@ fun ChatInputLayout( whisperTarget: UserName?, onWhisperDismiss: () -> Unit, onChangeRoomState: () -> Unit, - onInputActionsChanged: (List) -> Unit, + onInputActionsChanged: (ImmutableList) -> Unit, onSearchClick: () -> Unit = {}, onNewWhisper: (() -> Unit)? = null, showQuickActions: Boolean = true, @@ -191,7 +196,7 @@ fun ChatInputLayout( InputAction.RoomState -> isModerator else -> true } - } + }.toImmutableList() } var visibleActions by remember { mutableStateOf(effectiveActions) } @@ -299,22 +304,40 @@ fun ChatInputLayout( .focusRequester(focusRequester) .padding(bottom = 0.dp), // Reduce bottom padding as actions are below label = { Text(hint) }, - suffix = if (showCharacterCounter) { - { - val text = textFieldState.text.toString() - val codePointCount = text.codePointCount(0, text.length) - val isOverLimit = codePointCount > TWITCH_MESSAGE_CODE_POINT_LIMIT - Text( - text = "$codePointCount/$TWITCH_MESSAGE_CODE_POINT_LIMIT", - color = when { - isOverLimit -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - style = MaterialTheme.typography.labelSmall, - ) + suffix = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(IntrinsicSize.Min), + ) { + 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 && 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() }, + ) + } } - } else { - null }, colors = textFieldColors, shape = RoundedCornerShape(0.dp), @@ -371,7 +394,7 @@ fun ChatInputLayout( val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 val availableForActions = maxWidth - iconSize * fixedSlots val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) - visibleActions = effectiveActions.take(maxVisibleActions) + visibleActions = effectiveActions.take(maxVisibleActions).toImmutableList() Row( verticalAlignment = Alignment.CenterVertically, @@ -744,8 +767,8 @@ private fun getOverflowItem( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun InputActionConfigSheet( - inputActions: List, - onInputActionsChanged: (List) -> Unit, + inputActions: ImmutableList, + onInputActionsChanged: (ImmutableList) -> Unit, onDismiss: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -757,7 +780,7 @@ private fun InputActionConfigSheet( ModalBottomSheet( onDismissRequest = { - onInputActionsChanged(localEnabled.toList()) + onInputActionsChanged(localEnabled.toImmutableList()) onDismiss() }, sheetState = sheetState, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index a23c5b8be..80cd9b9e9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -90,6 +90,11 @@ class ChatInputViewModel( val whisperTarget: StateFlow = _whisperTarget.asStateFlow() // Create flow from TextFieldState tracking both text and cursor position + 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 @@ -242,8 +247,9 @@ class ChatInputViewModel( _uiState = combine( baseFlow, inputOverlayFlow, - helperText - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget), helperText -> + helperText, + codePointCount, + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget), helperText, codePoints -> val isMentionsTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 0 val isWhisperTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 1 val isInReplyThread = sheetState is FullScreenSheetState.Replies @@ -288,7 +294,11 @@ class ChatInputViewModel( helperText = helperText, showWhisperOverlay = showWhisperOverlay, whisperTarget = whisperTarget, - isWhisperTabActive = isWhisperTabActive + isWhisperTabActive = isWhisperTabActive, + characterCounter = CharacterCounterState.Visible( + text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", + isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, + ), ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) @@ -470,6 +480,7 @@ class ChatInputViewModel( companion object { private const val SUGGESTION_DEBOUNCE_MS = 20L + private const val MESSAGE_CODE_POINT_LIMIT = 500 } } @@ -513,5 +524,12 @@ data class ChatInputUiState( val helperText: String? = null, val showWhisperOverlay: Boolean = false, val whisperTarget: UserName? = null, - val isWhisperTabActive: Boolean = false + val isWhisperTabActive: Boolean = false, + val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, ) + +sealed interface CharacterCounterState { + data object Hidden : CharacterCounterState + @Immutable + data class Visible(val text: String, val isOverLimit: Boolean) : CharacterCounterState +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index f5e15eb18..79cbc5631 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -28,7 +28,6 @@ import com.flxrs.dankchat.chat.history.compose.MessageHistoryComposeViewModel import com.flxrs.dankchat.main.compose.sheets.MentionSheet import com.flxrs.dankchat.main.compose.sheets.MessageHistorySheet import com.flxrs.dankchat.main.compose.sheets.RepliesSheet -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -37,7 +36,6 @@ fun FullScreenSheetOverlay( sheetState: FullScreenSheetState, isLoggedIn: Boolean, mentionViewModel: MentionComposeViewModel, - appearanceSettingsDataStore: AppearanceSettingsDataStore, onDismiss: () -> Unit, onDismissReplies: () -> Unit, onUserClick: (UserPopupStateParams) -> Unit, @@ -105,7 +103,7 @@ fun FullScreenSheetOverlay( MentionSheet( mentionViewModel = mentionViewModel, initialisWhisperTab = false, - appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, onUserClick = userClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> @@ -131,7 +129,7 @@ fun FullScreenSheetOverlay( MentionSheet( mentionViewModel = mentionViewModel, initialisWhisperTab = true, - appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, onUserClick = userClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> @@ -156,7 +154,7 @@ fun FullScreenSheetOverlay( is FullScreenSheetState.Replies -> { RepliesSheet( rootMessageId = renderState.replyMessageId, - appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismissReplies, onUserClick = userClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> @@ -179,7 +177,7 @@ fun FullScreenSheetOverlay( viewModel = (currentHistoryViewModel ?: lastHistoryViewModel)!!, channel = renderState.channel, initialFilter = renderState.initialFilter, - appearanceSettingsDataStore = appearanceSettingsDataStore, + onDismiss = onDismiss, onUserClick = userClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index eb2ae2ad3..50c4fa675 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -113,13 +113,9 @@ import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.main.compose.sheets.EmoteMenu import com.flxrs.dankchat.onboarding.OnboardingDataStore 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 com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.tour.FeatureTourController import com.flxrs.dankchat.tour.PostOnboardingStep @@ -171,8 +167,6 @@ fun MainScreen( val streamViewModel: StreamViewModel = koinViewModel() val dialogViewModel: DialogStateViewModel = koinViewModel() val mentionViewModel: MentionComposeViewModel = koinViewModel() - val appearanceSettingsDataStore: AppearanceSettingsDataStore = koinInject() - val developerSettingsDataStore: DeveloperSettingsDataStore = koinInject() val preferenceStore: DankChatPreferenceStore = koinInject() val onboardingDataStore: OnboardingDataStore = koinInject() val mainEventBus: MainEventBus = koinInject() @@ -189,8 +183,7 @@ fun MainScreen( val configuration = LocalConfiguration.current val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE - val developerSettings by developerSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = developerSettingsDataStore.current()) - val isRepeatedSendEnabled = developerSettings.repeatedSending + val mainState by mainScreenViewModel.uiState.collectAsStateWithLifecycle() val ime = WindowInsets.ime val navBars = WindowInsets.navigationBars @@ -227,8 +220,9 @@ fun MainScreen( var backProgress by remember { mutableStateOf(0f) } // Stream state - val currentStream by streamViewModel.currentStreamedChannel.collectAsStateWithLifecycle() - val hasStreamData by streamViewModel.hasStreamData.collectAsStateWithLifecycle() + val streamVmState by streamViewModel.streamState.collectAsStateWithLifecycle() + val currentStream = streamVmState.currentStream + val hasStreamData = streamVmState.hasStreamData val imeTargetBottom = with(density) { WindowInsets.imeAnimationTarget.getBottom(density) } val streamState = rememberStreamToolbarState(currentStream, isKeyboardVisible, imeTargetBottom) @@ -280,12 +274,12 @@ fun MainScreen( val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() val toolsSettingsDataStore: ToolsSettingsDataStore = koinInject() - val userStateRepository: UserStateRepository = koinInject() - val fullScreenSheetState by sheetNavigationViewModel.fullScreenSheetState.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 by sheetNavigationViewModel.inputSheetState.collectAsStateWithLifecycle() + val inputSheetState = sheetNavState.inputSheet MainScreenEventHandler( resources = resources, @@ -425,29 +419,20 @@ fun MainScreen( ) } - val isFullscreen by mainScreenViewModel.isFullscreen.collectAsStateWithLifecycle() - val showInputState by mainScreenViewModel.showInput.collectAsStateWithLifecycle() - val inputActions by appearanceSettingsDataStore.inputActions.collectAsStateWithLifecycle( - initialValue = appearanceSettingsDataStore.current().inputActions - ) - val showCharacterCounter by appearanceSettingsDataStore.showCharacterCounter.collectAsStateWithLifecycle( - initialValue = appearanceSettingsDataStore.current().showCharacterCounter - ) - val gestureInputHidden by mainScreenViewModel.gestureInputHidden.collectAsStateWithLifecycle() - val gestureToolbarHidden by mainScreenViewModel.gestureToolbarHidden.collectAsStateWithLifecycle() - val effectiveShowInput = showInputState && !gestureInputHidden - val effectiveShowAppBar = !gestureToolbarHidden + val isFullscreen = mainState.isFullscreen + val effectiveShowInput = mainState.effectiveShowInput + val effectiveShowAppBar = mainState.effectiveShowAppBar // Auto-advance tour when input is hidden during the SwipeGesture step (e.g. by actual swipe) - LaunchedEffect(gestureInputHidden, tourController.currentStep) { - if (gestureInputHidden && tourController.currentStep == TourStep.SwipeGesture) { + LaunchedEffect(mainState.gestureInputHidden, tourController.currentStep) { + if (mainState.gestureInputHidden && tourController.currentStep == TourStep.SwipeGesture) { tourController.advance() } } // Keep toolbar visible during tour - LaunchedEffect(tourController.isActive, gestureToolbarHidden) { - if (tourController.isActive && gestureToolbarHidden) { + LaunchedEffect(tourController.isActive, mainState.gestureToolbarHidden) { + if (tourController.isActive && mainState.gestureToolbarHidden) { mainScreenViewModel.setGestureToolbarHidden(false) } } @@ -577,12 +562,12 @@ fun MainScreen( isUploading = dialogState.isUploading, isLoading = tabState.loading, isFullscreen = isFullscreen, - isModerator = userStateRepository.isModeratorInChannel(inputState.activeChannel), + isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), isStreamActive = currentStream != null, hasStreamData = hasStreamData, isSheetOpen = isSheetOpen, - inputActions = inputActions, - showCharacterCounter = showCharacterCounter, + inputActions = mainState.inputActions, + characterCounter = if (mainState.showCharacterCounter) inputState.characterCounter else CharacterCounterState.Hidden, onSend = chatInputViewModel::sendMessage, onLastMessageClick = chatInputViewModel::getLastMessage, onEmoteClick = { @@ -601,11 +586,7 @@ fun MainScreen( onChangeRoomState = dialogViewModel::showRoomState, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, - onInputActionsChanged = { newActions -> - scope.launch { - appearanceSettingsDataStore.update { it.copy(inputActions = newActions) } - } - }, + onInputActionsChanged = mainScreenViewModel::updateInputActions, onInputHeightChanged = { inputHeightPx = it }, instantHide = isHistorySheet, tourState = TourOverlayState( @@ -792,7 +773,7 @@ fun MainScreen( showFabs = !isSheetOpen, onRecover = { if (isFullscreen) mainScreenViewModel.toggleFullscreen() - if (!showInputState) mainScreenViewModel.toggleInput() + if (!mainState.showInput) mainScreenViewModel.toggleInput() mainScreenViewModel.resetGestureState() }, contentPadding = PaddingValues( @@ -862,7 +843,6 @@ fun MainScreen( sheetState = fullScreenSheetState, isLoggedIn = isLoggedIn, mentionViewModel = mentionViewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, onDismiss = sheetNavigationViewModel::closeFullScreenSheet, onDismissReplies = { sheetNavigationViewModel.closeFullScreenSheet() @@ -950,7 +930,7 @@ fun MainScreen( ) // Status bar scrim when toolbar is gesture-hidden - if (!isFullscreen && gestureToolbarHidden) { + if (!isFullscreen && mainState.gestureToolbarHidden) { Box( modifier = Modifier .align(Alignment.TopCenter) @@ -1106,7 +1086,7 @@ fun MainScreen( ) // Status bar scrim when toolbar is gesture-hidden — keeps status bar readable - if (!isInPipMode && !isFullscreen && gestureToolbarHidden) { + if (!isInPipMode && !isFullscreen && mainState.gestureToolbarHidden) { Box( modifier = Modifier .align(Alignment.TopCenter) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index 4c699aef7..01f008eb8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -1,17 +1,26 @@ package com.flxrs.dankchat.main.compose +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.UserName 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.persistentListOf +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.map import kotlinx.coroutines.flow.stateIn @@ -36,24 +45,37 @@ class MainScreenViewModel( private val channelDataCoordinator: ChannelDataCoordinator, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, + private val developerSettingsDataStore: DeveloperSettingsDataStore, + private val userStateRepository: UserStateRepository, ) : ViewModel() { // Only expose truly global state - val globalLoadingState: StateFlow = + val globalLoadingState: StateFlow = channelDataCoordinator.globalLoadingState - val showInput: StateFlow = appearanceSettingsDataStore.settings - .map { it.showInput } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) - private val _isFullscreen = MutableStateFlow(false) - val isFullscreen: StateFlow = _isFullscreen.asStateFlow() - private val _gestureInputHidden = MutableStateFlow(false) - val gestureInputHidden: StateFlow = _gestureInputHidden.asStateFlow() - private val _gestureToolbarHidden = MutableStateFlow(false) - val gestureToolbarHidden: StateFlow = _gestureToolbarHidden.asStateFlow() + + val uiState: StateFlow = combine( + appearanceSettingsDataStore.settings, + developerSettingsDataStore.settings.map { it.repeatedSending }, + _isFullscreen, + _gestureInputHidden, + _gestureToolbarHidden, + ) { appearance, repeatedSending, isFullscreen, gestureInputHidden, gestureToolbarHidden -> + MainScreenUiState( + isFullscreen = isFullscreen, + showInput = appearance.showInput, + inputActions = appearance.inputActions.toImmutableList(), + showCharacterCounter = appearance.showCharacterCounter, + isRepeatedSendEnabled = repeatedSending, + gestureInputHidden = gestureInputHidden, + gestureToolbarHidden = gestureToolbarHidden, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainScreenUiState()) + + fun isModeratorInChannel(channel: UserName?): Boolean = userStateRepository.isModeratorInChannel(channel) // Keyboard height persistence — debounced to avoid thrashing during animation private val _keyboardHeightUpdates = MutableSharedFlow(extraBufferCapacity = 1) @@ -107,6 +129,12 @@ class MainScreenViewModel( } } + fun updateInputActions(actions: ImmutableList) { + viewModelScope.launch { + appearanceSettingsDataStore.update { it.copy(inputActions = actions) } + } + } + fun toggleFullscreen() { _isFullscreen.update { !it } } @@ -117,3 +145,17 @@ class MainScreenViewModel( } private data class KeyboardHeightUpdate(val heightPx: Int, val isLandscape: Boolean) + +@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 gestureInputHidden: Boolean = false, + val gestureToolbarHidden: Boolean = false, +) { + val effectiveShowInput: Boolean get() = showInput && !gestureInputHidden + val effectiveShowAppBar: Boolean get() = !gestureToolbarHidden +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt index 32707e223..21bd599a0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt @@ -2,10 +2,14 @@ package com.flxrs.dankchat.main.compose import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.UserName 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.android.annotation.KoinViewModel @KoinViewModel @@ -15,7 +19,13 @@ class SheetNavigationViewModel : ViewModel() { val fullScreenSheetState: StateFlow = _fullScreenSheetState.asStateFlow() private val _inputSheetState = MutableStateFlow(InputSheetState.Closed) - val inputSheetState: StateFlow = _inputSheetState.asStateFlow() + + 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) @@ -77,3 +87,9 @@ sealed interface InputSheetState { data object EmoteMenu : InputSheetState @Immutable data class MoreActions(val messageId: String, val fullMessage: String) : 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/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt index 3f01145e0..f9eb43de2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -68,7 +68,7 @@ fun StreamView( (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.currentStreamedChannel.value == null) { + if (streamViewModel.streamState.value.currentStream == null) { streamViewModel.destroyWebView(webView) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt index 1b1582751..29c8aba3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.repo.stream.StreamDataRepository @@ -29,16 +30,8 @@ class StreamViewModel( ) : AndroidViewModel(application) { private val _currentStreamedChannel = MutableStateFlow(null) - val currentStreamedChannel: StateFlow = _currentStreamedChannel.asStateFlow() - val shouldEnablePipAutoMode: StateFlow = combine( - currentStreamedChannel, - streamsSettingsDataStore.pipEnabled, - ) { currentStream, pipEnabled -> - currentStream != null && pipEnabled - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - - val hasStreamData: StateFlow = combine( + private val hasStreamData: StateFlow = combine( chatRepository.activeChannel, streamDataRepository.streamData ) { activeChannel, streamData -> @@ -46,6 +39,20 @@ class StreamViewModel( }.distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val streamState: StateFlow = combine( + _currentStreamedChannel, + hasStreamData, + ) { currentStream, hasData -> + StreamState(currentStream = currentStream, hasStreamData = hasData) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StreamState()) + + val shouldEnablePipAutoMode: StateFlow = combine( + _currentStreamedChannel, + streamsSettingsDataStore.pipEnabled, + ) { currentStream, pipEnabled -> + currentStream != null && pipEnabled + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + init { viewModelScope.launch { chatRepository.channels.collect { channels -> @@ -109,3 +116,9 @@ class StreamViewModel( super.onCleared() } } + +@Immutable +data class StreamState( + val currentStream: UserName? = null, + val hasStreamData: Boolean = false, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt index d95b33f0f..02753573e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt @@ -35,10 +35,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.flxrs.dankchat.chat.suggestion.Suggestion +import kotlinx.collections.immutable.ImmutableList @Composable fun SuggestionDropdown( - suggestions: List, + suggestions: ImmutableList, onSuggestionClick: (Suggestion) -> Unit, modifier: Modifier = Modifier ) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index d78609c52..fe847a76c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -44,7 +44,6 @@ import com.flxrs.dankchat.chat.mention.compose.MentionComposable import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @@ -52,7 +51,6 @@ import kotlinx.coroutines.launch fun MentionSheet( mentionViewModel: MentionComposeViewModel, initialisWhisperTab: Boolean, - appearanceSettingsDataStore: AppearanceSettingsDataStore, onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -112,7 +110,6 @@ fun MentionSheet( ) { page -> MentionComposable( mentionViewModel = mentionViewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, isWhisperTab = page == 1, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt index b7bc81284..68e71f6e5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -63,17 +63,15 @@ import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.compose.SuggestionDropdown import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.chat.compose.ChatDisplaySettings +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException -import org.koin.compose.koinInject @Composable fun MessageHistorySheet( viewModel: MessageHistoryComposeViewModel, channel: UserName, initialFilter: String, - appearanceSettingsDataStore: AppearanceSettingsDataStore, onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -85,11 +83,7 @@ fun MessageHistorySheet( viewModel.setInitialQuery(initialFilter) } - val chatSettingsDataStore: ChatSettingsDataStore = koinInject() - val appearanceSettings by appearanceSettingsDataStore.settings.collectAsStateWithLifecycle( - initialValue = appearanceSettingsDataStore.current() - ) - val chatSettings by chatSettingsDataStore.settings.collectAsStateWithLifecycle(initialValue = chatSettingsDataStore.current()) + val displaySettings by viewModel.chatDisplaySettings.collectAsStateWithLifecycle() val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val filterSuggestions by viewModel.filterSuggestions.collectAsStateWithLifecycle() @@ -142,9 +136,9 @@ fun MessageHistorySheet( CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, - fontSize = appearanceSettings.fontSize.toFloat(), - showLineSeparator = appearanceSettings.lineSeparator, - animateGifs = chatSettings.animateGifs, + fontSize = displaySettings.fontSize, + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, modifier = Modifier.fillMaxSize(), onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, @@ -211,7 +205,7 @@ fun MessageHistorySheet( // Filter suggestions above search bar SuggestionDropdown( - suggestions = filterSuggestions, + suggestions = filterSuggestions.toImmutableList(), onSuggestionClick = { suggestion -> viewModel.applySuggestion(suggestion) }, modifier = Modifier .align(Alignment.BottomStart) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index dbdedc3fa..58db59f79 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -35,7 +35,6 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.replies.compose.RepliesComposable import com.flxrs.dankchat.chat.replies.compose.RepliesComposeViewModel -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.CancellationException import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -43,7 +42,6 @@ import org.koin.core.parameter.parametersOf @Composable fun RepliesSheet( rootMessageId: String, - appearanceSettingsDataStore: AppearanceSettingsDataStore, onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, @@ -90,7 +88,6 @@ fun RepliesSheet( // Chat content - edge to edge RepliesComposable( repliesViewModel = viewModel, - appearanceSettingsDataStore = appearanceSettingsDataStore, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onNotFound = onDismiss, 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 index 33731282c..599e58959 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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 @@ -265,6 +266,7 @@ private fun ThemeCategory( } } +@Immutable data class ThemeState( val preference: ThemePreference, val summary: String, 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 048ec60fe..6d658ff37 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import androidx.compose.runtime.Immutable import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @@ -62,6 +63,7 @@ sealed interface AppearanceSettingsInteraction { data class ShowCharacterCounter(val value: Boolean) : AppearanceSettingsInteraction } +@Immutable data class AppearanceSettingsUiState( val settings: AppearanceSettings, val useComposeUi: 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..878db0cea 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,6 +2,7 @@ package com.flxrs.dankchat.preferences.chat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow @@ -96,6 +97,7 @@ sealed interface ChatSettingsInteraction { data class ChatModes(val value: Boolean) : ChatSettingsInteraction } +@Immutable data class ChatSettingsState( val suggestions: Boolean, val preferEmoteSuggestions: Boolean, From 327ff666daff4257fbc81bf11c8c01af700cdbfe Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 15 Mar 2026 19:40:47 +0100 Subject: [PATCH 045/349] refactor(compose): Replace Popup overflow menu with inline overlay --- .../dankchat/main/compose/ChatBottomBar.kt | 4 + .../dankchat/main/compose/ChatInputLayout.kt | 354 +++--------------- .../flxrs/dankchat/main/compose/MainScreen.kt | 37 ++ .../dankchat/main/compose/QuickActionsMenu.kt | 290 ++++++++++++++ 4 files changed, 375 insertions(+), 310 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index 3d94fbff4..aa3bc6e96 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -52,6 +52,8 @@ fun ChatBottomBar( onSearchClick: () -> Unit, onNewWhisper: (() -> Unit)?, onInputActionsChanged: (ImmutableList) -> Unit, + overflowExpanded: Boolean = false, + onOverflowExpandedChanged: (Boolean) -> Unit = {}, onInputHeightChanged: (Int) -> Unit, instantHide: Boolean = false, tourState: TourOverlayState = TourOverlayState(), @@ -96,6 +98,8 @@ fun ChatBottomBar( onInputActionsChanged = onInputActionsChanged, onSearchClick = onSearchClick, onNewWhisper = onNewWhisper, + overflowExpanded = overflowExpanded, + onOverflowExpandedChanged = onOverflowExpandedChanged, showQuickActions = !isSheetOpen, tourState = tourState, modifier = Modifier.onGloballyPositioned { coordinates -> diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index bb5e5de7f..fbff7673d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -2,9 +2,7 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility import androidx.compose.runtime.Immutable -import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -26,7 +24,6 @@ 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.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits @@ -91,18 +88,9 @@ 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.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.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties -import androidx.compose.foundation.Canvas -import androidx.compose.ui.graphics.Path import androidx.compose.ui.platform.LocalDensity import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName @@ -159,6 +147,8 @@ fun ChatInputLayout( onInputActionsChanged: (ImmutableList) -> Unit, onSearchClick: () -> Unit = {}, onNewWhisper: (() -> Unit)? = null, + overflowExpanded: Boolean = false, + onOverflowExpandedChanged: (Boolean) -> Unit = {}, showQuickActions: Boolean = true, tourState: TourOverlayState = TourOverlayState(), modifier: Modifier = Modifier @@ -200,8 +190,7 @@ fun ChatInputLayout( } var visibleActions by remember { mutableStateOf(effectiveActions) } - var userExpandedMenu by remember { mutableStateOf(false) } - val quickActionsExpanded = userExpandedMenu || tourState.forceOverflowOpen + val quickActionsExpanded = overflowExpanded || tourState.forceOverflowOpen var showConfigSheet by remember { mutableStateOf(false) } val topEndRadius by animateDpAsState( targetValue = if (quickActionsExpanded) 0.dp else 24.dp, @@ -431,7 +420,7 @@ fun ChatInputLayout( if (tourState.overflowMenuTooltipState != null) { tourState.onAdvance?.invoke() } else { - userExpandedMenu = !quickActionsExpanded + onOverflowExpandedChanged(!quickActionsExpanded) } }, modifier = Modifier.size(iconSize) @@ -529,33 +518,6 @@ fun ChatInputLayout( actionsRowContent() } } - - QuickActionsOverflowMenu( - expanded = quickActionsExpanded, - surfaceColor = surfaceColor, - visibleActions = visibleActions, - isStreamActive = isStreamActive, - hasStreamData = hasStreamData, - isFullscreen = isFullscreen, - isModerator = isModerator, - tourState = tourState, - onDismiss = { if (!tourState.forceOverflowOpen) userExpandedMenu = false }, - onActionClick = { action -> - when (action) { - InputAction.Search -> onSearchClick() - InputAction.LastMessage -> onLastMessageClick() - InputAction.Stream -> onToggleStream() - InputAction.RoomState -> onChangeRoomState() - InputAction.Fullscreen -> onToggleFullscreen() - InputAction.HideInput -> onToggleInput() - } - userExpandedMenu = false - }, - onConfigureClick = { - userExpandedMenu = false - showConfigSheet = true - }, - ) } Box(modifier = modifier.fillMaxWidth()) { @@ -577,191 +539,55 @@ fun ChatInputLayout( } else { inputContent() } - } - if (showConfigSheet) { - InputActionConfigSheet( - inputActions = inputActions, - onInputActionsChanged = onInputActionsChanged, - onDismiss = { showConfigSheet = false }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun QuickActionsOverflowMenu( - expanded: Boolean, - surfaceColor: Color, - visibleActions: List, - isStreamActive: Boolean, - hasStreamData: Boolean, - isFullscreen: Boolean, - isModerator: Boolean, - tourState: TourOverlayState, - onDismiss: () -> Unit, - onActionClick: (InputAction) -> Unit, - onConfigureClick: () -> Unit, -) { - val menuVisibleState = remember { MutableTransitionState(false) } - menuVisibleState.targetState = expanded - - if (!menuVisibleState.currentState && !menuVisibleState.targetState) return - - val positionProvider = remember { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset = IntOffset( - x = anchorBounds.right - popupContentSize.width, - y = anchorBounds.top - popupContentSize.height - ) - } - } - - Popup( - popupPositionProvider = positionProvider, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = true), - ) { + // Overflow menu — overlays above input, end-aligned AnimatedVisibility( - visibleState = menuVisibleState, - enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = tween(durationMillis = 150) - ) + fadeIn(animationSpec = tween(durationMillis = 100)), - exit = shrinkVertically( - shrinkTowards = Alignment.Bottom, - animationSpec = tween(durationMillis = 120) - ) + fadeOut(animationSpec = tween(durationMillis = 80)), - ) { - Surface( - shape = RoundedCornerShape(topStart = 12.dp), - color = surfaceColor, - ) { - 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) { - DropdownMenuItem( - text = { Text(stringResource(overflowItem.labelRes)) }, - onClick = { onActionClick(action) }, - leadingIcon = { - Icon( - imageVector = overflowItem.icon, - contentDescription = null - ) - } - ) - } - } - - HorizontalDivider() - - val configureItem: @Composable () -> Unit = { - DropdownMenuItem( - text = { Text(stringResource(R.string.input_action_configure)) }, - onClick = { - if (tourState.configureActionsTooltipState != null) { - tourState.onAdvance?.invoke() - } else { - onConfigureClick() - } - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null - ) - } - ) + 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) } - if (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() + }, + ) { + QuickActionsMenu( + surfaceColor = surfaceColor, + visibleActions = visibleActions, + isStreamActive = isStreamActive, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = isModerator, + tourState = tourState, + onActionClick = { action -> + when (action) { + InputAction.Search -> onSearchClick() + InputAction.LastMessage -> onLastMessageClick() + InputAction.Stream -> onToggleStream() + InputAction.RoomState -> onChangeRoomState() + InputAction.Fullscreen -> onToggleFullscreen() + InputAction.HideInput -> onToggleInput() } - } - } + onOverflowExpandedChanged(false) + }, + onConfigureClick = { + onOverflowExpandedChanged(false) + showConfigSheet = true + }, + ) } } -} -@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.Default.VideocamOff else Icons.Default.Videocam, - ) - - else -> null - } - - InputAction.RoomState -> when { - isModerator -> OverflowItem( - labelRes = R.string.menu_room_state, - icon = Icons.Default.Shield, + if (showConfigSheet) { + InputActionConfigSheet( + inputActions = inputActions, + onInputActionsChanged = onInputActionsChanged, + onDismiss = { showConfigSheet = false }, ) - - 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, - ) } @OptIn(ExperimentalMaterial3Api::class) @@ -1012,95 +838,3 @@ internal fun TooltipScope.TourTooltip( } } -/** - * 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.surfaceContainerHigh - 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.onSurfaceVariant, - ) - 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) - } - } -} - -/** - * Positions the tooltip to the start (left in LTR) of the anchor, vertically centered. - * Falls back to above-positioning if there's not enough horizontal space. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private 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) { - // Fits to the start — vertically center on anchor - val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 - IntOffset( - startX, - y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)), - ) - } else { - // Not enough space — fall back to above, horizontally end-aligned with anchor - 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/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 50c4fa675..bde698206 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -484,6 +486,7 @@ fun MainScreen( pageCount = { pagerState.channels.size } ).also { composePagerStateRef = it } var inputHeightPx by remember { mutableIntStateOf(0) } + var inputOverflowExpanded by remember { mutableStateOf(false) } if (!effectiveShowInput) inputHeightPx = 0 val inputHeightDp = with(density) { inputHeightPx.toDp() } // scaffoldBottomContentPadding removed — input bar rendered outside Scaffold @@ -587,6 +590,8 @@ fun MainScreen( onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, onInputActionsChanged = mainScreenViewModel::updateInputActions, + overflowExpanded = inputOverflowExpanded, + onOverflowExpandedChanged = { inputOverflowExpanded = it }, onInputHeightChanged = { inputHeightPx = it }, instantHide = isHistorySheet, tourState = TourOverlayState( @@ -942,6 +947,22 @@ fun MainScreen( fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + // Dismiss scrim for input overflow menu + if (inputOverflowExpanded) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (!tourController.forceOverflowOpen) { + inputOverflowExpanded = false + } + } + ) + } + // Input bar - rendered after sheet overlay so it's on top Box( modifier = Modifier @@ -1049,6 +1070,22 @@ fun MainScreen( fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) } + // Dismiss scrim for input overflow menu + if (!isInPipMode && inputOverflowExpanded) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (!tourController.forceOverflowOpen) { + inputOverflowExpanded = false + } + } + ) + } + // Input bar - rendered after sheet overlay so it's on top if (!isInPipMode) { Box( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt new file mode 100644 index 000000000..675c72d79 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt @@ -0,0 +1,290 @@ +package com.flxrs.dankchat.main.compose + +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.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +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.Shield +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff +import androidx.compose.material.icons.filled.VisibilityOff +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.material3.TooltipDefaults +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 kotlinx.collections.immutable.ImmutableList + +/** + * Inline overflow menu for input actions that don't fit in the quick actions bar. + * Renders as a Surface with rounded top corners, designed to sit directly above + * the input Surface in a Column layout. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickActionsMenu( + surfaceColor: Color, + visibleActions: ImmutableList, + isStreamActive: Boolean, + hasStreamData: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + tourState: TourOverlayState, + onActionClick: (InputAction) -> 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) { + DropdownMenuItem( + text = { Text(stringResource(overflowItem.labelRes)) }, + onClick = { onActionClick(action) }, + leadingIcon = { + Icon( + imageVector = overflowItem.icon, + 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.Default.VideocamOff else Icons.Default.Videocam, + ) + + else -> null + } + + InputAction.RoomState -> when { + isModerator -> OverflowItem( + labelRes = R.string.menu_room_state, + icon = Icons.Default.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, + ) +} + +/** + * 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.surfaceContainerHigh + 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.onSurfaceVariant, + ) + 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) + } + } +} + +/** + * Positions the tooltip to the start (left in LTR) of the anchor, vertically centered. + * Falls back to above-positioning if there's not enough horizontal space. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private 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) { + // Fits to the start — vertically center on anchor + val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 + IntOffset( + startX, + y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)), + ) + } else { + // Not enough space — fall back to above, horizontally end-aligned with anchor + 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) + } + } + } + } +} From bdad0e1f266aa5de4d6b591a5d899f3256317522 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 15 Mar 2026 20:48:27 +0100 Subject: [PATCH 046/349] feat(share): Add share intent upload with floating dialog UI --- app/src/main/AndroidManifest.xml | 13 + .../dankchat/onboarding/OnboardingScreen.kt | 2 +- .../dankchat/share/ShareUploadActivity.kt | 283 ++++++++++++++++++ app/src/main/res/values-be-rBY/strings.xml | 3 + app/src/main/res/values-ca/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en-rAU/strings.xml | 3 + app/src/main/res/values-en-rGB/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 3 + app/src/main/res/values-fi-rFI/strings.xml | 3 + app/src/main/res/values-fr-rFR/strings.xml | 3 + app/src/main/res/values-hu-rHU/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja-rJP/strings.xml | 3 + app/src/main/res/values-pl-rPL/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt-rPT/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values-sr/strings.xml | 3 + app/src/main/res/values-tr-rTR/strings.xml | 3 + app/src/main/res/values-uk-rUA/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 8 + 25 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 483e2b67f..fb01be804 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,6 +47,19 @@ + + + + + + + + + (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.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + 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(Dispatchers.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 (e: 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(Dispatchers.IO) { dataRepository.uploadMedia(file) } + result.fold( + onSuccess = { url -> uploadState = ShareUploadState.Success(url) }, + onFailure = { error -> + uploadState = ShareUploadState.Error( + 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/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index e7dd08f81..a65597fd2 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -57,6 +57,9 @@ Загрузка завершана: %1$s Памылка пры загрузцы Памылка пры загрузцы: %1$s + Загрузіць + Скапіравана ў буфер абмену + Капіяваць URL Паўтарыць Смайлы абноўлены Памылка загрузкі дадзеных: %1$s diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 8d4d2601c..d403a98ef 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -54,6 +54,9 @@ 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9c13e3a0f..51b8bbdb9 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -57,6 +57,9 @@ 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 4caac01c9..5d4c44796 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -57,6 +57,9 @@ 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 92d6333cf..46443b243 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -54,6 +54,9 @@ 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 1ff6c0e39..d054b453d 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -54,6 +54,9 @@ 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a1f73df7f..fb148536e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -57,6 +57,9 @@ 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 01079297e..0ce798fbe 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -56,6 +56,9 @@ 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 651cbed3a..1f1c8e913 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -56,6 +56,9 @@ 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 450aa4a5f..9ad5ab0e9 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -57,6 +57,9 @@ 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 80e172688..235fbc1c9 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -57,6 +57,9 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3fd15e1a6..2ca020513 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -56,6 +56,9 @@ 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 9b695df44..e8edc6b56 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -56,6 +56,9 @@ アップロード完了:%1$s アップロード中にエラー アップロード中にエラー:%1$s + アップロード + クリップボードにコピーしました + URLをコピー 再試行 エモートをリロードしました データの読み込みに失敗しました:%1$s diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 0205037a7..bd4cd3911 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -56,6 +56,9 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index db7c484ad..69e0daf51 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -57,6 +57,9 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 4a30133fc..1550b8228 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -57,6 +57,9 @@ 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 7140a7e36..b97132520 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -57,6 +57,9 @@ Загрузка завершена: %1$s Ошибка при загрузке Ошибка при загрузке: %1$s + Загрузить + Скопировано в буфер обмена + Копировать URL Повторить Смайлы обновлены Ошибка загрузки данных: %1$s diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index bda947fd2..a686ae60a 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -49,6 +49,9 @@ 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 diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 23ebd70fb..f987e98f9 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -56,6 +56,9 @@ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index b04728b19..c80fc5596 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -57,6 +57,9 @@ Завантаження завершено: %1$s Помилка при завантаженні Помилка при завантаженні: %1$s + Завантажити + Скопійовано до буфера обміну + Копіювати URL Повторити Смайли оновлені Помилка завантаження даних: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c7ee3934..38a051613 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,9 @@ 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 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c9ad23932..824efe02e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -14,6 +14,14 @@ + - - - diff --git a/build.gradle.kts b/build.gradle.kts index 856650337..e4561a892 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,6 @@ plugins { 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 } From ff4a5d1d609bcd5b7bcdd81a239d68c36bc3d246 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 09:43:29 +0100 Subject: [PATCH 049/349] fix(compose): Handle 7TV emote update events in ChannelDataCoordinator --- .../dankchat/domain/ChannelDataCoordinator.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index f0e9b4553..241a589fd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -14,6 +14,8 @@ import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep 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.twitch.message.SystemMessageType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -49,6 +51,28 @@ class ChannelDataCoordinator( private val _globalLoadingState = MutableStateFlow(GlobalLoadingState.Idle) val globalLoadingState: StateFlow = _globalLoadingState.asStateFlow() + init { + scope.launch { + dataRepository.dataUpdateEvents.collect { event -> + when (event) { + is DataUpdateEventMessage.ActiveEmoteSetChanged -> { + chatRepository.makeAndPostSystemMessage( + type = SystemMessageType.ChannelSevenTVEmoteSetChanged(event.actorName, event.emoteSetName), + channel = event.channel + ) + } + + is DataUpdateEventMessage.EmoteSetUpdated -> { + val (channel, update) = event + update.added.forEach { chatRepository.makeAndPostSystemMessage(SystemMessageType.ChannelSevenTVEmoteAdded(update.actorName, it.name), channel) } + update.updated.forEach { chatRepository.makeAndPostSystemMessage(SystemMessageType.ChannelSevenTVEmoteRenamed(update.actorName, it.oldName, it.name), channel) } + update.removed.forEach { chatRepository.makeAndPostSystemMessage(SystemMessageType.ChannelSevenTVEmoteRemoved(update.actorName, it.name), channel) } + } + } + } + } + } + /** * Get loading state for a specific channel */ From de83456449eec36aefd5ccb7aadae210202710a1 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 10:52:26 +0100 Subject: [PATCH 050/349] feat(i18n): Add translations for whisper, room state, menu, input actions, and onboarding strings --- app/src/main/res/values-be-rBY/strings.xml | 65 +++++++++++++++++++ app/src/main/res/values-ca/strings.xml | 63 +++++++++++++++++++ app/src/main/res/values-cs/strings.xml | 65 +++++++++++++++++++ app/src/main/res/values-de-rDE/strings.xml | 61 ++++++++++++++++++ app/src/main/res/values-en-rAU/strings.xml | 50 +++++++++++++++ app/src/main/res/values-en-rGB/strings.xml | 50 +++++++++++++++ app/src/main/res/values-en/strings.xml | 61 ++++++++++++++++++ app/src/main/res/values-es-rES/strings.xml | 63 +++++++++++++++++++ app/src/main/res/values-fi-rFI/strings.xml | 61 ++++++++++++++++++ app/src/main/res/values-fr-rFR/strings.xml | 63 +++++++++++++++++++ app/src/main/res/values-hu-rHU/strings.xml | 61 ++++++++++++++++++ app/src/main/res/values-it/strings.xml | 63 +++++++++++++++++++ app/src/main/res/values-ja-rJP/strings.xml | 60 ++++++++++++++++++ app/src/main/res/values-pl-rPL/strings.xml | 65 +++++++++++++++++++ app/src/main/res/values-pt-rBR/strings.xml | 63 +++++++++++++++++++ app/src/main/res/values-pt-rPT/strings.xml | 73 ++++++++++++++++++++-- app/src/main/res/values-ru-rRU/strings.xml | 65 +++++++++++++++++++ app/src/main/res/values-sr/strings.xml | 63 +++++++++++++++++++ app/src/main/res/values-tr-rTR/strings.xml | 61 ++++++++++++++++++ app/src/main/res/values-uk-rUA/strings.xml | 65 +++++++++++++++++++ app/src/main/res/values/strings.xml | 8 --- 21 files changed, 1236 insertions(+), 13 deletions(-) diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index e9e344b61..1b487a1d3 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -447,6 +447,8 @@ %1$s (узровень %2$d) супадае з %1$d заблакаваным тэрмінам %2$s + супадае з %1$d заблакаванымі тэрмінамі %2$s + супадае з %1$d заблакаванымі тэрмінамі %2$s супадае з %1$d заблакаванымі тэрмінамі %2$s Не ўдалося %1$s паведамленне AutoMod - паведамленне ўжо апрацавана. @@ -458,4 +460,67 @@ %1$s дадаў %2$s як дазволены тэрмін у AutoMod. %1$s выдаліў %2$s як заблакаваны тэрмін з AutoMod. %1$s выдаліў %2$s як дазволены тэрмін з AutoMod. + + + Выдаліць + Адправіць шэпт + Шэпт да @%1$s + Новы шэпт + Адправіць шэпт да + Імя карыстальніка + Адправіць + + + Толькі эмоуты + Толькі падпісчыкі + Павольны рэжым + Унікальны чат (R9K) + Толькі фалаверы + + + Дадайце канал, каб пачаць размову + Няма нядаўніх эмоутаў + + + Паказаць трансляцыю + Схаваць трансляцыю + На ўвесь экран + Выйсці з поўнаэкраннага рэжыму + Схаваць увод + Налады канала + + + Пошук паведамленняў + Апошняе паведамленне + Пераключыць трансляцыю + Налады канала + На ўвесь экран + Схаваць увод + Наладзіць дзеянні + + Максімум %1$d дзеянне + Максімум %1$d дзеянні + Максімум %1$d дзеянняў + Максімум %1$d дзеянняў + + + + DankChat + Давайце ўсё наладзім. + Увайсці праз Twitch + Увайдзіце, каб адпраўляць паведамленні, выкарыстоўваць свае эмоуты, атрымліваць шэпты і разблакаваць усе функцыі. + Увайсці праз Twitch + Уваход паспяховы + Апавяшчэнні + DankChat можа апавяшчаць вас, калі нехта згадвае вас у чаце, пакуль праграма працуе ў фоне. + Дазволіць апавяшчэнні + Адкрыць налады апавяшчэнняў + Без апавяшчэнняў вы не даведаецеся, калі нехта згадвае вас у чаце, пакуль праграма працуе ў фоне. + Гісторыя паведамленняў + DankChat загружае гістарычныя паведамленні са старонняга сэрвісу пры запуску.\nДля атрымання паведамленняў DankChat адпраўляе назвы адкрытых каналаў гэтаму сэрвісу.\nСэрвіс часова захоўвае паведамленні наведаных каналаў.\n\nВы можаце змяніць гэта пазней у наладах або даведацца больш на https://recent-messages.robotty.de/ + Уключыць + Адключыць + Працягнуць + Пачаць + Прапусціць diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index dfbe2c394..4f32f3e39 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -336,6 +336,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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. @@ -347,4 +348,66 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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. + + + Retrocés + Envia un xiuxiueig + Xiuxiuejant a @%1$s + Nou xiuxiueig + Envia xiuxiueig a + Nom d\'usuari + Enviar + + + Només emotes + Només subscriptors + Mode lent + Xat únic (R9K) + Només seguidors + + + Afegeix un canal per començar a xatejar + Cap emote recent + + + Mostra l\'emissió + Amaga l\'emissió + Pantalla completa + Surt de la pantalla completa + Amaga l\'entrada + Configuració del canal + + + Cerca missatges + Últim missatge + Commuta l\'emissió + Configuració del canal + Pantalla completa + Amaga l\'entrada + Configura accions + + Màxim %1$d acció + Màxim %1$d accions + Màxim %1$d accions + + + + 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. + 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.\nPer obtenir els missatges, DankChat envia els noms dels canals oberts a aquest servei.\nEl 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index c599db4cc..6adeadbeb 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -448,6 +448,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %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. @@ -459,4 +461,67 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %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. + + + 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 + Unikátní chat (R9K) + Pouze sledující + + + Přidejte kanál a začněte chatovat + Žádné nedávné emotikony + + + Zobrazit stream + Skrýt stream + Celá obrazovka + Ukončit celou obrazovku + Skrýt vstup + Nastavení kanálu + + + Hledat zprávy + Poslední zpráva + Přepnout stream + Nastavení kanálu + Celá obrazovka + Skrýt vstup + Konfigurovat akce + + 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í. + 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í.\nPro získání zpráv DankChat odesílá názvy otevřených kanálů této službě.\nSluž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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 84fa00532..6c03abd6e 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -474,4 +474,65 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %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. + + + 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 + Einzigartiger Chat (R9K) + Nur Follower + + + Füge einen Kanal hinzu, um zu chatten + Keine kürzlich verwendeten Emotes + + + Stream anzeigen + Stream ausblenden + Vollbild + Vollbild beenden + Eingabe ausblenden + Kanaleinstellungen + + + Nachrichten durchsuchen + Letzte Nachricht + Stream umschalten + Kanaleinstellungen + Vollbild + Eingabe ausblenden + Aktionen konfigurieren + + 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. + 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.\nUm die Nachrichten abzurufen, sendet DankChat die Namen der geöffneten Kanäle an diesen Dienst.\nDer 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 08c4b5b11..c0f08b176 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -284,4 +284,54 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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. + + Backspace + Send a whisper + Whispering @%1$s + New whisper + Send whisper to + Username + Start + Emote only + Subscriber only + Slow mode + Unique chat (R9K) + Follower only + Add a channel to start chatting + No recent emotes + Show stream + Hide stream + Fullscreen + Exit fullscreen + Hide input + Channel settings + Search messages + Last message + Toggle stream + Channel settings + Fullscreen + Hide input + Configure actions + + 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. + 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.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 378118143..edd2b84e5 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -285,4 +285,54 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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. + + Backspace + Send a whisper + Whispering @%1$s + New whisper + Send whisper to + Username + Start + Emote only + Subscriber only + Slow mode + Unique chat (R9K) + Follower only + Add a channel to start chatting + No recent emotes + Show stream + Hide stream + Fullscreen + Exit fullscreen + Hide input + Channel settings + Search messages + Last message + Toggle stream + Channel settings + Fullscreen + Hide input + Configure actions + + 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. + 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.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 6f42b9ede..9ccf42cb6 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -467,4 +467,65 @@ %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. + + + Backspace + Send a whisper + Whispering @%1$s + New whisper + Send whisper to + Username + Start + + + Emote only + Subscriber only + Slow mode + Unique chat (R9K) + Follower only + + + Add a channel to start chatting + No recent emotes + + + Show stream + Hide stream + Fullscreen + Exit fullscreen + Hide input + Channel settings + + + Search messages + Last message + Toggle stream + Channel settings + Fullscreen + Hide input + Configure actions + + 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. + 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.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 70f41d770..c3fe08a7d 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -462,6 +462,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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. @@ -473,4 +474,66 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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. + + + Retroceso + Enviar un susurro + Susurrando a @%1$s + Nuevo susurro + Enviar susurro a + Nombre de usuario + Enviar + + + Solo emotes + Solo suscriptores + Modo lento + Chat único (R9K) + Solo seguidores + + + Añade un canal para empezar a chatear + No hay emotes recientes + + + Mostrar stream + Ocultar stream + Pantalla completa + Salir de pantalla completa + Ocultar entrada + Ajustes del canal + + + Buscar mensajes + Último mensaje + Alternar stream + Ajustes del canal + Pantalla completa + Ocultar entrada + Configurar acciones + + 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. + 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.\nPara obtener los mensajes, DankChat envía los nombres de los canales abiertos a ese servicio.\nEl 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 1bf9d3faf..bf668cf8b 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -311,4 +311,65 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %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. + + + 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 + Ainutlaatuinen chat (R9K) + Vain seuraajat + + + Lisää kanava aloittaaksesi keskustelun + Ei viimeaikaisia hymiöitä + + + Näytä lähetys + Piilota lähetys + Koko näyttö + Poistu koko näytöstä + Piilota syöttö + Kanavan asetukset + + + Hae viestejä + Viimeisin viesti + Vaihda lähetys + Kanavan asetukset + Koko näyttö + Piilota syöttö + Muokkaa toimintoja + + 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. + 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ä.\nViestien hakemiseksi DankChat lähettää avattujen kanavien nimet tälle palvelulle.\nPalvelu 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index ca20eb467..99daf6a36 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -446,6 +446,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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é. @@ -457,4 +458,66 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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. + + + 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 + Chat unique (R9K) + Abonnés uniquement + + + Ajoutez une chaîne pour commencer à discuter + Aucun emote récent + + + Afficher le stream + Masquer le stream + Plein écran + Quitter le plein écran + Masquer la saisie + Paramètres de la chaîne + + + Rechercher des messages + Dernier message + Basculer le stream + Paramètres de la chaîne + Plein écran + Masquer la saisie + Configurer les actions + + 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. + 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.\nPour obtenir les messages, DankChat envoie les noms des chaînes ouvertes à ce service.\nLe 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 08584ffcc..f6a0987f7 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -452,4 +452,65 @@ %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. + + + 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 + Egyedi chat (R9K) + Csak követők + + + 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 + Teljes képernyő + Kilépés a teljes képernyőből + Bevitel elrejtése + Csatornabeállítások + + + Üzenetek keresése + Utolsó üzenet + Közvetítés váltása + Csatornabeállítások + Teljes képernyő + Bevitel elrejtése + Műveletek beállítása + + 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. + 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.\nAz üzenetek lekéréséhez a DankChat elküldi a megnyitott csatornák neveit ennek a szolgáltatásnak.\nA 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 76765cdbd..2278a37f6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -430,6 +430,7 @@ %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. @@ -441,4 +442,66 @@ %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. + + + Cancella + Invia un sussurro + Sussurro a @%1$s + Nuovo sussurro + Invia sussurro a + Nome utente + Invia + + + Solo emote + Solo abbonati + Modalità lenta + Chat unica (R9K) + Solo follower + + + Aggiungi un canale per iniziare a chattare + Nessuna emote recente + + + Mostra stream + Nascondi stream + Schermo intero + Esci dallo schermo intero + Nascondi input + Impostazioni canale + + + Cerca messaggi + Ultimo messaggio + Attiva/disattiva stream + Impostazioni canale + Schermo intero + Nascondi input + Configura azioni + + 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à. + 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.\nPer ottenere i messaggi, DankChat invia i nomi dei canali aperti a questo servizio.\nIl 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 98d98f4d1..2baa0a3c1 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -439,4 +439,64 @@ %1$sがAutoModで%2$sを許可用語として追加しました。 %1$sがAutoModから%2$sをブロック用語として削除しました。 %1$sがAutoModから%2$sを許可用語として削除しました。 + + + バックスペース + ウィスパーを送信 + @%1$s にウィスパー中 + 新しいウィスパー + ウィスパーの送信先 + ユーザー名 + 送信 + + + エモートのみ + サブスクライバーのみ + スローモード + ユニークチャット (R9K) + フォロワーのみ + + + チャンネルを追加してチャットを始めましょう + 最近使用したエモートはありません + + + 配信を表示 + 配信を非表示 + 全画面 + 全画面を終了 + 入力欄を非表示 + チャンネル設定 + + + メッセージを検索 + 最後のメッセージ + 配信を切り替え + チャンネル設定 + 全画面 + 入力欄を非表示 + アクションを設定 + + 最大%1$d個のアクション + + + + DankChat + セットアップを始めましょう。 + Twitchでログイン + ログインして、メッセージの送信、エモートの使用、ウィスパーの受信、すべての機能をお楽しみください。 + Twitchでログイン + ログイン成功 + 通知 + DankChatは、アプリがバックグラウンドにある時にチャットで誰かがあなたをメンションした場合に通知できます。 + 通知を許可 + 通知設定を開く + 通知がないと、アプリがバックグラウンドにある時にチャットで誰かがあなたをメンションしても気づけません。 + メッセージ履歴 + DankChatは起動時にサードパーティサービスから過去のメッセージを読み込みます。\nメッセージを取得するために、DankChatは開いているチャンネル名をそのサービスに送信します。\nサービスは訪問されたチャンネルのメッセージを一時的に保存します。\n\nこれは後で設定から変更できます。詳細は https://recent-messages.robotty.de/ をご覧ください。 + 有効にする + 無効にする + 続ける + 始める + スキップ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 355f96d55..d01ad080e 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -466,6 +466,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %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. @@ -477,4 +479,67 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %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. + + + 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 + Unikalny czat (R9K) + Tylko obserwujący + + + Dodaj kanał, aby zacząć czatować + Brak ostatnich emotek + + + Pokaż transmisję + Ukryj transmisję + Pełny ekran + Wyjdź z pełnego ekranu + Ukryj pole wpisywania + Ustawienia kanału + + + Szukaj wiadomości + Ostatnia wiadomość + Przełącz transmisję + Ustawienia kanału + Pełny ekran + Ukryj pole wpisywania + Konfiguruj akcje + + 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. + 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.\nAby pobrać wiadomości, DankChat wysyła nazwy otwartych kanałów do tego serwisu.\nSerwis 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ń diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3d8ba09ae..b92484591 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -441,6 +441,7 @@ %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. @@ -452,4 +453,66 @@ %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. + + + Apagar + Enviar um sussurro + Sussurrando para @%1$s + Novo sussurro + Enviar sussurro para + Nome de usuário + Enviar + + + Apenas emotes + Apenas assinantes + Modo lento + Chat único (R9K) + Apenas seguidores + + + Adicione um canal para começar a conversar + Nenhum emote recente + + + Mostrar stream + Ocultar stream + Tela cheia + Sair da tela cheia + Ocultar entrada + Configurações do canal + + + Pesquisar mensagens + Última mensagem + Alternar stream + Configurações do canal + Tela cheia + Ocultar entrada + Configurar ações + + 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. + 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.\nPara obter as mensagens, o DankChat envia os nomes dos canais abertos para esse serviço.\nO 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 1b2e35427..466b4d69e 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 @@ -282,7 +282,7 @@ 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 7TV atualização de emotes ao vivo @@ -389,9 +389,9 @@ 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 @@ -432,6 +432,7 @@ %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. @@ -443,4 +444,66 @@ %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. + + + Apagar + Enviar um sussurro + Sussurrar a @%1$s + Novo sussurro + Enviar sussurro a + Nome de utilizador + Enviar + + + Apenas emotes + Apenas subscritores + Modo lento + Chat único (R9K) + Apenas seguidores + + + Adicione um canal para começar a conversar + Nenhum emote recente + + + Mostrar transmissão + Ocultar transmissão + Ecrã inteiro + Sair do ecrã inteiro + Ocultar entrada + Definições do canal + + + Pesquisar mensagens + Última mensagem + Alternar transmissão + Definições do canal + Ecrã inteiro + Ocultar entrada + Configurar ações + + 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. + 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.\nPara obter as mensagens, o DankChat envia os nomes dos canais abertos para esse serviço.\nO 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 426c7a862..b38d98072 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -452,6 +452,8 @@ %1$s (уровень %2$d) совпадает с %1$d заблокированным термином %2$s + совпадает с %1$d заблокированными терминами %2$s + совпадает с %1$d заблокированными терминами %2$s совпадает с %1$d заблокированными терминами %2$s Не удалось %1$s сообщение AutoMod - сообщение уже обработано. @@ -463,4 +465,67 @@ %1$s добавил %2$s как разрешённый термин в AutoMod. %1$s удалил %2$s как заблокированный термин из AutoMod. %1$s удалил %2$s как разрешённый термин из AutoMod. + + + Удалить + Отправить личное сообщение + Личное сообщение для @%1$s + Новое личное сообщение + Отправить личное сообщение + Имя пользователя + Отправить + + + Только эмоуты + Только подписчики + Медленный режим + Уникальный чат (R9K) + Только фолловеры + + + Добавьте канал, чтобы начать общение + Нет недавних эмоутов + + + Показать трансляцию + Скрыть трансляцию + На весь экран + Выйти из полноэкранного режима + Скрыть ввод + Настройки канала + + + Поиск сообщений + Последнее сообщение + Переключить трансляцию + Настройки канала + На весь экран + Скрыть ввод + Настроить действия + + Максимум %1$d действие + Максимум %1$d действия + Максимум %1$d действий + Максимум %1$d действий + + + + DankChat + Давайте настроим всё. + Войти через Twitch + Войдите, чтобы отправлять сообщения, использовать свои эмоуты, получать личные сообщения и разблокировать все функции. + Войти через Twitch + Вход выполнен + Уведомления + DankChat может уведомлять вас, когда кто-то упоминает вас в чате, пока приложение работает в фоне. + Разрешить уведомления + Открыть настройки уведомлений + Без уведомлений вы не узнаете, когда кто-то упоминает вас в чате, пока приложение работает в фоне. + История сообщений + DankChat загружает историю сообщений из стороннего сервиса при запуске.\nДля получения сообщений DankChat отправляет названия открытых каналов этому сервису.\nСервис временно хранит сообщения посещённых каналов.\n\nВы можете изменить это позже в настройках или узнать больше на https://recent-messages.robotty.de/ + Включить + Отключить + Продолжить + Начать + Пропустить diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 179acb1dc..8c997a99b 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -241,6 +241,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%1$s (ниво %2$d) подудара се са %1$d блокираним термином %2$s + подудара се са %1$d блокирана термина %2$s подудара се са %1$d блокираних термина %2$s Није успело %1$s AutoMod поруке - порука је већ обрађена. @@ -252,4 +253,66 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%1$s је додао %2$s као дозвољени термин на AutoMod. %1$s је уклонио %2$s као блокирани термин са AutoMod. %1$s је уклонио %2$s као дозвољени термин са AutoMod. + + + Обриши + Пошаљи шапат + Шапат ка @%1$s + Нови шапат + Пошаљи шапат + Корисничко име + Пошаљи + + + Само емотикони + Само претплатници + Спори режим + Јединствени чат (R9K) + Само пратиоци + + + Додајте канал да бисте почели да ћаскате + Нема недавних емотикона + + + Прикажи стрим + Сакриј стрим + Цео екран + Изађи из целог екрана + Сакриј унос + Подешавања канала + + + Претражи поруке + Последња порука + Укључи/искључи стрим + Подешавања канала + Цео екран + Сакриј унос + Подеси акције + + Максимално %1$d акција + Максимално %1$d акције + Максимално %1$d акција + + + + DankChat + Хајде да подесимо све. + Пријави се преко Twitch-а + Пријавите се да бисте слали поруке, користили емотиконе, примали шапате и откључали све функције. + Пријави се преко Twitch-а + Пријава успешна + Обавештења + DankChat може да вас обавести када вас неко помене у чату док апликација ради у позадини. + Дозволи обавештења + Отвори подешавања обавештења + Без обавештења нећете знати када вас неко помене у чату док апликација ради у позадини. + Историја порука + DankChat учитава историјске поруке из услуге треће стране при покретању.\nДа би добио поруке, DankChat шаље имена отворених канала овој услузи.\nУслуга привремено чува поруке посећених канала.\n\nОво можете касније променити у подешавањима или сазнати више на https://recent-messages.robotty.de/ + Укључи + Искључи + Настави + Почни + Прескочи diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index a2a44f127..1cd167b00 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -473,4 +473,65 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %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ı. + + + 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 + Benzersiz sohbet (R9K) + Yalnızca takipçiler + + + Sohbete başlamak için bir kanal ekleyin + Son kullanılan emote yok + + + Yayını göster + Yayını gizle + Tam ekran + Tam ekrandan çık + Girişi gizle + Kanal ayarları + + + Mesajları ara + Son mesaj + Yayını aç/kapat + Kanal ayarları + Tam ekran + Girişi gizle + Eylemleri yapılandır + + 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. + 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.\nMesajları almak için DankChat, açık kanalların adlarını bu hizmete gönderir.\nHizmet, 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index db8e4531b..571db797d 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -449,6 +449,8 @@ %1$s (рівень %2$d) збігається з %1$d заблокованим терміном %2$s + збігається з %1$d заблокованими термінами %2$s + збігається з %1$d заблокованими термінами %2$s збігається з %1$d заблокованими термінами %2$s Не вдалося %1$s повідомлення AutoMod - повідомлення вже оброблено. @@ -460,4 +462,67 @@ %1$s додав %2$s як дозволений термін у AutoMod. %1$s видалив %2$s як заблокований термін з AutoMod. %1$s видалив %2$s як дозволений термін з AutoMod. + + + Видалити + Надіслати шепіт + Шепіт до @%1$s + Новий шепіт + Надіслати шепіт до + Ім\'я користувача + Надіслати + + + Лише емоути + Лише підписники + Повільний режим + Унікальний чат (R9K) + Лише фоловери + + + Додайте канал, щоб почати спілкування + Немає нещодавніх емоутів + + + Показати трансляцію + Сховати трансляцію + На весь екран + Вийти з повноекранного режиму + Сховати введення + Налаштування каналу + + + Пошук повідомлень + Останнє повідомлення + Перемкнути трансляцію + Налаштування каналу + На весь екран + Сховати введення + Налаштувати дії + + Максимум %1$d дія + Максимум %1$d дії + Максимум %1$d дій + Максимум %1$d дій + + + + DankChat + Давайте все налаштуємо. + Увійти через Twitch + Увійдіть, щоб надсилати повідомлення, використовувати свої емоути, отримувати шепіт та розблокувати всі функції. + Увійти через Twitch + Вхід виконано + Сповіщення + DankChat може сповіщувати вас, коли хтось згадує вас у чаті, поки застосунок працює у фоні. + Дозволити сповіщення + Відкрити налаштування сповіщень + Без сповіщень ви не дізнаєтесь, коли хтось згадує вас у чаті, поки застосунок працює у фоні. + Історія повідомлень + DankChat завантажує історичні повідомлення зі стороннього сервісу при запуску.\nДля отримання повідомлень DankChat надсилає назви відкритих каналів цьому сервісу.\nСервіс тимчасово зберігає повідомлення відвіданих каналів.\n\nВи можете змінити це пізніше в налаштуваннях або дізнатися більше на https://recent-messages.robotty.de/ + Увімкнути + Вимкнути + Продовжити + Почати + Пропустити diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3356b9049..cb3bace09 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,7 +166,6 @@ Exit fullscreen Hide input Channel settings - Input actions Maximum of %1$d action Maximum of %1$d actions @@ -572,13 +571,6 @@ Allow Notifications Without notifications, you won\'t know when someone mentions you in chat while the app is in the background. Open Notification Settings - Battery Optimization - DankChat runs a background service for notifications. Some devices aggressively kill background apps, which can cause missed notifications.\n\nExcluding DankChat from battery optimization helps keep it running reliably. - Open Settings - Battery optimization disabled - You\'re all set! - Start by adding a Twitch channel to chat in. - Start Customizable actions for quick access to search, streams, and more From 407c27649dae149c05b33001a13ad8c6553de291 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 10:55:58 +0100 Subject: [PATCH 051/349] fix(i18n): Add missing many plural forms and fix Portuguese typos --- app/src/main/res/values-es-rES/strings.xml | 3 +++ app/src/main/res/values-fr-rFR/strings.xml | 3 +++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 3 +++ app/src/main/res/values-pt-rPT/strings.xml | 2 ++ 5 files changed, 13 insertions(+) diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index c3fe08a7d..a51592b3f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -407,15 +407,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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 99daf6a36..4169ce185 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -403,15 +403,18 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2278a37f6..6ea440624 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -395,10 +395,12 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b92484591..a9bf60635 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -398,15 +398,18 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 466b4d69e..beb8ca33a 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -397,10 +397,12 @@ 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 From 96f01d57a80c702fdea5a09a2595c6ce4638a490 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 11:13:02 +0100 Subject: [PATCH 052/349] fix(compose): Implement emote name copy to clipboard in emote info dialog --- .../com/flxrs/dankchat/main/compose/MainScreenDialogs.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index 574f3d9f2..9fa0e7099 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -245,7 +245,11 @@ fun MainScreenDialogs( items = viewModel.items, isLoggedIn = isLoggedIn, onUseEmote = { chatInputViewModel.insertText("$it ") }, - onCopyEmote = { /* TODO: copy to clipboard */ }, + onCopyEmote = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("emote", it))) + } + }, onOpenLink = { onOpenUrl(it) }, onDismiss = dialogViewModel::dismissEmoteInfo ) From 1323aa38200628be7c2d9aa457907cf59e89b8c0 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 11:15:08 +0100 Subject: [PATCH 053/349] fix: Remove debug println in UserDisplayViewModel --- .../preferences/chat/userdisplay/UserDisplayViewModel.kt | 1 - 1 file changed, 1 deletion(-) 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..34b83fa6e 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 @@ -30,7 +30,6 @@ 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)) } From e38b24fabf9e4be22697a4a2cf335d95ec42dbf1 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 11:42:19 +0100 Subject: [PATCH 054/349] feat(i18n): Localize moderation and automod reason strings across 20 locales --- .../chat/compose/ChatMessageUiState.kt | 4 +- .../dankchat/chat/compose/TextResource.kt | 15 +++ .../chat/compose/messages/AutomodMessage.kt | 3 +- .../chat/compose/messages/SystemMessages.kt | 2 +- .../dankchat/data/repo/chat/ChatRepository.kt | 11 +- .../data/twitch/message/AutomodMessage.kt | 3 +- .../data/twitch/message/ModerationMessage.kt | 124 ++++++++++-------- .../main/res/drawable/ic_automod_badge.png | Bin 218 -> 0 bytes app/src/main/res/values-be-rBY/strings.xml | 60 +++++++++ app/src/main/res/values-ca/strings.xml | 61 +++++++++ app/src/main/res/values-cs/strings.xml | 60 +++++++++ app/src/main/res/values-de-rDE/strings.xml | 61 +++++++++ app/src/main/res/values-en-rAU/strings.xml | 54 ++++++++ app/src/main/res/values-en-rGB/strings.xml | 54 ++++++++ app/src/main/res/values-en/strings.xml | 54 ++++++++ app/src/main/res/values-es-rES/strings.xml | 61 +++++++++ app/src/main/res/values-fi-rFI/strings.xml | 54 ++++++++ app/src/main/res/values-fr-rFR/strings.xml | 61 +++++++++ app/src/main/res/values-hu-rHU/strings.xml | 54 ++++++++ app/src/main/res/values-it/strings.xml | 61 +++++++++ app/src/main/res/values-ja-rJP/strings.xml | 51 +++++++ app/src/main/res/values-pl-rPL/strings.xml | 60 +++++++++ app/src/main/res/values-pt-rBR/strings.xml | 57 ++++++++ app/src/main/res/values-pt-rPT/strings.xml | 57 ++++++++ app/src/main/res/values-ru-rRU/strings.xml | 60 +++++++++ app/src/main/res/values-sr/strings.xml | 57 ++++++++ app/src/main/res/values-tr-rTR/strings.xml | 54 ++++++++ app/src/main/res/values-uk-rUA/strings.xml | 60 +++++++++ app/src/main/res/values/strings.xml | 63 ++++++++- 29 files changed, 1314 insertions(+), 62 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_automod_badge.png diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index ed1b69e9b..79952613b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -110,7 +110,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, - val message: String, + val message: TextResource, ) : ChatMessageUiState /** @@ -165,7 +165,7 @@ sealed interface ChatMessageUiState { val userDisplayName: String, val rawNameColor: Int, val messageText: String, - val reason: String, + val reason: TextResource, val status: AutomodMessageStatus, ) : ChatMessageUiState { enum class AutomodMessageStatus { Pending, Approved, Denied, Expired } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt index f74587ac8..54f83560f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt @@ -1,8 +1,10 @@ package com.flxrs.dankchat.chat.compose +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 @Immutable @@ -12,6 +14,9 @@ sealed interface TextResource { @Immutable data class Res(@StringRes val id: Int, val args: List = emptyList()) : TextResource + + @Immutable + data class PluralRes(@PluralsRes val id: Int, val quantity: Int, val args: List = emptyList()) : TextResource } @Composable @@ -26,4 +31,14 @@ fun TextResource.resolve(): String = when (this) { } 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/chat/compose/messages/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt index 2cdd20007..3ea049f56 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt @@ -31,6 +31,7 @@ import com.flxrs.dankchat.chat.compose.EmoteDimensions import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.rememberNormalizedColor +import com.flxrs.dankchat.chat.compose.resolve import com.flxrs.dankchat.data.UserName private val AutoModBlue = Color(0xFF448AFF) @@ -56,7 +57,7 @@ fun AutomodMessageComposable( val nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) // Resolve strings - val headerText = stringResource(R.string.automod_header, message.reason) + 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) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index 440779264..ab5fe1d2c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -208,7 +208,7 @@ fun ModerationMessageComposable( modifier: Modifier = Modifier, ) { SimpleMessageContainer( - message = message.message, + message = message.message.resolve(), timestamp = message.timestamp, fontSize = fontSize.sp, lightBackgroundColor = message.lightBackgroundColor, 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 3cc6d3336..ecc7e789f 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 @@ -34,6 +34,8 @@ import com.flxrs.dankchat.data.twitch.chat.ChatEvent import com.flxrs.dankchat.data.twitch.chat.ConnectionState import com.flxrs.dankchat.data.twitch.badge.Badge import com.flxrs.dankchat.data.twitch.badge.BadgeType +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.TextResource import com.flxrs.dankchat.data.twitch.message.AutomodMessage import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.ModerationMessage @@ -978,18 +980,19 @@ class ChatRepository( automod: AutomodReasonDto?, blockedTerm: BlockedTermReasonDto?, messageText: String, - ): String = when { - reason == "automod" && automod != null -> "${automod.category} (level ${automod.level})" + ): TextResource = when { + reason == "automod" && automod != null -> TextResource.Res(R.string.automod_reason_category, listOf(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)}\"" } - "blocked term: $terms" + val count = blockedTerm.termsFound.size + TextResource.PluralRes(R.plurals.automod_reason_blocked_terms, count, listOf(count, terms)) } - else -> reason + else -> TextResource.Plain(reason) } fun updateAutomodMessageStatus(channel: UserName, heldMessageId: String, status: AutomodMessage.Status) { 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 index 8ec0aabc2..437c0e123 100644 --- 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 @@ -1,5 +1,6 @@ package com.flxrs.dankchat.data.twitch.message +import com.flxrs.dankchat.chat.compose.TextResource import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.badge.Badge @@ -13,7 +14,7 @@ data class AutomodMessage( val userName: UserName, val userDisplayName: DisplayName, val messageText: String, - val reason: String, + val reason: TextResource, val badges: List = emptyList(), val color: Int = Message.DEFAULT_COLOR, val status: Status = Status.Pending, 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 c60e36d5b..89663fcc5 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,7 @@ package com.flxrs.dankchat.data.twitch.message +import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.TextResource import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelModerateAction @@ -68,86 +70,104 @@ data class ModerationMessage( RemovePermittedTerm, } - 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 val durationSuffix: TextResource + get() = duration?.let { TextResource.Res(R.string.mod_duration_suffix, listOf(it)) } ?: TextResource.Plain("") + private val creatorSuffix: TextResource + get() = creatorUserDisplay?.let { TextResource.Res(R.string.mod_by_creator_suffix, listOf(it.toString())) } ?: TextResource.Plain("") + private val quotedReasonSuffix: TextResource + get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reason_suffix, listOf(it)) } ?: TextResource.Plain("") + private val reasonsSuffix: TextResource + get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reasons_suffix, listOf(it)) } ?: TextResource.Plain("") private val quotedTermsOrBlank get() = reason.takeUnless { it.isNullOrBlank() } ?: "terms" - private fun getTrimmedReasonOrBlank(showDeletedMessage: Boolean): String { - if (!showDeletedMessage) return "" + + private fun sayingSuffix(showDeletedMessage: Boolean): TextResource { + if (!showDeletedMessage) return TextResource.Plain("") val fullReason = reason.orEmpty() val trimmed = when { fullReason.length > 50 -> "${fullReason.take(50)}…" else -> fullReason } - return " saying: \"$trimmed\"" + return TextResource.Res(R.string.mod_saying_suffix, listOf(trimmed)) } - private val creatorOrBlank get() = creatorUserDisplay?.let { " by $it" }.orEmpty() - private val countOrBlank - get() = when { - stackCount > 1 -> " ($stackCount times)" - else -> "" + private fun countSuffix(): TextResource { + return when { + stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, listOf(stackCount)) + else -> TextResource.Plain("") } + } - // TODO localize - fun getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): String { + private fun minutesSuffix(): TextResource { + return durationInt?.takeIf { it > 0 }?.let { TextResource.PluralRes(R.plurals.mod_minutes_suffix, it, listOf(it)) } ?: TextResource.Plain("") + } + + private fun secondsSuffix(): TextResource { + return durationInt?.let { TextResource.PluralRes(R.plurals.mod_seconds_suffix, it, listOf(it)) } ?: TextResource.Plain("") + } + + fun getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): TextResource { return when (action) { Action.Timeout -> when (targetUser) { - currentUser -> "You were timed out$durationOrBlank$creatorOrBlank$quotedReasonOrBlank.$countOrBlank" + currentUser -> TextResource.Res(R.string.mod_timeout_self, listOf(durationSuffix, creatorSuffix, quotedReasonSuffix, countSuffix())) else -> when (creatorUserDisplay) { - null -> "$targetUserDisplay has been timed out$durationOrBlank.$countOrBlank" // irc - else -> "$creatorUserDisplay timed out $targetUserDisplay$durationOrBlank.$countOrBlank" + null -> TextResource.Res(R.string.mod_timeout_no_creator, listOf(targetUserDisplay.toString(), durationSuffix, countSuffix())) + else -> TextResource.Res(R.string.mod_timeout_by_creator, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, countSuffix())) } } - Action.Untimeout -> "$creatorUserDisplay untimedout $targetUserDisplay." + Action.Untimeout -> TextResource.Res(R.string.mod_untimeout, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) Action.Ban -> when (targetUser) { - currentUser -> "You were banned$creatorOrBlank$quotedReasonOrBlank." + currentUser -> TextResource.Res(R.string.mod_ban_self, listOf(creatorSuffix, quotedReasonSuffix)) else -> when (creatorUserDisplay) { - null -> "$targetUserDisplay has been permanently banned." // irc - else -> "$creatorUserDisplay banned $targetUserDisplay$quotedReasonOrBlank." - + null -> TextResource.Res(R.string.mod_ban_no_creator, listOf(targetUserDisplay.toString())) + else -> TextResource.Res(R.string.mod_ban_by_creator, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), quotedReasonSuffix)) } } - Action.Unban -> "$creatorUserDisplay unbanned $targetUserDisplay." - Action.Mod -> "$creatorUserDisplay modded $targetUserDisplay." - Action.Unmod -> "$creatorUserDisplay unmodded $targetUserDisplay." + Action.Unban -> TextResource.Res(R.string.mod_unban, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Mod -> TextResource.Res(R.string.mod_modded, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unmod -> TextResource.Res(R.string.mod_unmodded, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) Action.Delete -> when (creatorUserDisplay) { - null -> "A message from $targetUserDisplay was deleted${getTrimmedReasonOrBlank(showDeletedMessage)}." - else -> "$creatorUserDisplay deleted message from $targetUserDisplay${getTrimmedReasonOrBlank(showDeletedMessage)}." + null -> TextResource.Res(R.string.mod_delete_no_creator, listOf(targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) + else -> TextResource.Res(R.string.mod_delete_by_creator, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) } Action.Clear -> when (creatorUserDisplay) { - null -> "Chat has been cleared by a moderator." - else -> "$creatorUserDisplay cleared the chat." + null -> TextResource.Res(R.string.mod_clear_no_creator) + else -> TextResource.Res(R.string.mod_clear_by_creator, listOf(creatorUserDisplay.toString())) } - 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.AddBlockedTerm -> "$creatorUserDisplay added $quotedTermsOrBlank as a blocked term on AutoMod." - Action.AddPermittedTerm -> "$creatorUserDisplay added $quotedTermsOrBlank as a permitted term on AutoMod." - Action.RemoveBlockedTerm -> "$creatorUserDisplay removed $quotedTermsOrBlank as a blocked term on AutoMod." - Action.RemovePermittedTerm -> "$creatorUserDisplay removed $quotedTermsOrBlank as a permitted term on AutoMod." + Action.Vip -> TextResource.Res(R.string.mod_vip_added, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unvip -> TextResource.Res(R.string.mod_vip_removed, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Warn -> { + val suffix = when (val r = reasonsSuffix) { + is TextResource.Plain -> TextResource.Plain(".") + else -> r + } + TextResource.Res(R.string.mod_warn, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), suffix)) + } + Action.Raid -> TextResource.Res(R.string.mod_raid, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unraid -> TextResource.Res(R.string.mod_unraid, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.EmoteOnly -> TextResource.Res(R.string.mod_emote_only_on, listOf(creatorUserDisplay.toString())) + Action.EmoteOnlyOff -> TextResource.Res(R.string.mod_emote_only_off, listOf(creatorUserDisplay.toString())) + Action.Followers -> TextResource.Res(R.string.mod_followers_on, listOf(creatorUserDisplay.toString(), minutesSuffix())) + Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, listOf(creatorUserDisplay.toString())) + Action.UniqueChat -> TextResource.Res(R.string.mod_unique_chat_on, listOf(creatorUserDisplay.toString())) + Action.UniqueChatOff -> TextResource.Res(R.string.mod_unique_chat_off, listOf(creatorUserDisplay.toString())) + Action.Slow -> TextResource.Res(R.string.mod_slow_on, listOf(creatorUserDisplay.toString(), secondsSuffix())) + Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, listOf(creatorUserDisplay.toString())) + Action.Subscribers -> TextResource.Res(R.string.mod_subscribers_on, listOf(creatorUserDisplay.toString())) + Action.SubscribersOff -> TextResource.Res(R.string.mod_subscribers_off, listOf(creatorUserDisplay.toString())) + Action.SharedTimeout -> TextResource.Res(R.string.mod_shared_timeout, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, sourceBroadcasterDisplay.toString(), countSuffix())) + Action.SharedUntimeout -> TextResource.Res(R.string.mod_shared_untimeout, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) + Action.SharedBan -> TextResource.Res(R.string.mod_shared_ban, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), quotedReasonSuffix)) + Action.SharedUnban -> TextResource.Res(R.string.mod_shared_unban, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) + Action.SharedDelete -> TextResource.Res(R.string.mod_shared_delete, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), sayingSuffix(showDeletedMessage))) + Action.AddBlockedTerm -> TextResource.Res(R.string.automod_moderation_added_blocked_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.AddPermittedTerm -> TextResource.Res(R.string.automod_moderation_added_permitted_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.RemoveBlockedTerm -> TextResource.Res(R.string.automod_moderation_removed_blocked_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.RemovePermittedTerm -> TextResource.Res(R.string.automod_moderation_removed_permitted_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) } } diff --git a/app/src/main/res/drawable/ic_automod_badge.png b/app/src/main/res/drawable/ic_automod_badge.png deleted file mode 100644 index e1f468b74f855e362885e6b0e203f1825f9a2631..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh-3?!F4n4AKn6a#!hTp8A}GOTB1Sj+VP|NpnY z-X48(Y{{dg4c8hYPDUy2P-a-m%vry1BTzAONswP~e8Tf%tQ+-#+*D5&$B>F!$q7-c zj9ffX0oRn+6gC>=icMhIl(s}-c4%1$s выдаліў %2$s як заблакаваны тэрмін з AutoMod. %1$s выдаліў %2$s як дазволены тэрмін з AutoMod. + + + Вас было заглушана%1$s%2$s%3$s.%4$s + %1$s заглушыў %2$s%3$s.%4$s + %1$s быў заглушаны%2$s.%3$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%3$s + %1$s пачаў рэйд на %2$s. + %1$s адмяніў рэйд на %2$s. + %1$s выдаліў паведамленне ад %2$s%3$s. + Паведамленне ад %1$s было выдалена%2$s. + %1$s ачысціў чат. + Чат быў ачышчаны мадэратарам. + %1$s уключыў рэжым толькі эмоцыі. + %1$s выключыў рэжым толькі эмоцыі. + %1$s уключыў рэжым толькі для падпісчыкаў канала.%2$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.%5$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%4$s + на %1$s + ад %1$s + : \"%1$s\" + : %1$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 секунд) + + Выдаліць Адправіць шэпт diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 4f32f3e39..aceeb24a7 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -349,6 +349,67 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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%1$s%2$s%3$s.%4$s + %1$s ha expulsat temporalment %2$s%3$s.%4$s + %1$s ha estat expulsat temporalment%2$s.%3$s + + Has estat banejat%1$s%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%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%3$s. + Un missatge de %1$s ha estat eliminat%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.%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.%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%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%4$s. + %1$s ha desbanejat %2$s a %3$s. + %1$s ha eliminat un missatge de %2$s a %3$s%4$s + + per %1$s + per %1$s + : \"%1$s\" + : %1$s + dient: \"%1$s\" + + (%1$d vegada) + (%1$d vegades) + + + (%1$d minut) + (%1$d minuts) + + + (%1$d segon) + (%1$d segons) + + Retrocés Envia un xiuxiueig diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6adeadbeb..de725ee5c 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -462,6 +462,66 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %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%1$s%2$s%3$s.%4$s + %1$s ztlumil/a %2$s%3$s.%4$s + %1$s byl/a ztlumen/a%2$s.%3$s + Byl/a jste zabanován/a%1$s%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%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%3$s. + Zpráva od %1$s byla smazána%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í.%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.%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%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%4$s. + %1$s odbanoval/a %2$s v %3$s. + %1$s smazal/a zprávu od %2$s v %3$s%4$s + na %1$s + od %1$s + : \"%1$s\" + : %1$s + s textem: \"%1$s\" + + (%1$d krát) + (%1$d krát) + (%1$d krát) + (%1$d krát) + + + (%1$d minuta) + (%1$d minuty) + (%1$d minut) + (%1$d minut) + + + (%1$d sekunda) + (%1$d sekundy) + (%1$d sekund) + (%1$d sekund) + + Smazat Odeslat šepot diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 6c03abd6e..d402dadef 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -475,6 +475,67 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %1$s hat %2$s als blockierten Begriff von AutoMod entfernt. %1$s hat %2$s als erlaubten Begriff von AutoMod entfernt. + + + Du wurdest getimeouted%1$s%2$s%3$s.%4$s + %1$s hat %2$s getimeouted%3$s.%4$s + %1$s wurde getimeouted%2$s.%3$s + + Du wurdest gebannt%1$s%2$s. + %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%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%3$s. + Eine Nachricht von %1$s wurde gelöscht%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.%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.%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%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%4$s. + %1$s hat %2$s in %3$s entbannt. + %1$s hat eine Nachricht von %2$s in %3$s gelöscht%4$s + + für %1$s + von %1$s + : \"%1$s\" + : %1$s + mit dem Inhalt: \"%1$s\" + + (%1$d Mal) + (%1$d Mal) + + + (%1$d Minute) + (%1$d Minuten) + + + (%1$d Sekunde) + (%1$d Sekunden) + + Rücktaste Flüsternachricht senden diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index c0f08b176..393bacf8e 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -285,6 +285,60 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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%1$s%2$s%3$s.%4$s + %1$s timed out %2$s%3$s.%4$s + %1$s has been timed out%2$s.%3$s + You were banned%1$s%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%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%3$s. + A message from %1$s was deleted%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.%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.%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%3$s in %4$s.%5$s + %1$s untimedout %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%4$s + for %1$s + by %1$s + : \"%1$s\" + : %1$s + saying: \"%1$s\" + + (%1$d time) + (%1$d times) + + + (%1$d minute) + (%1$d minutes) + + + (%1$d second) + (%1$d seconds) + + Backspace Send a whisper Whispering @%1$s diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index edd2b84e5..cec0801fd 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -286,6 +286,60 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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%1$s%2$s%3$s.%4$s + %1$s timed out %2$s%3$s.%4$s + %1$s has been timed out%2$s.%3$s + You were banned%1$s%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%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%3$s. + A message from %1$s was deleted%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.%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.%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%3$s in %4$s.%5$s + %1$s untimedout %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%4$s + for %1$s + by %1$s + : \"%1$s\" + : %1$s + saying: \"%1$s\" + + (%1$d time) + (%1$d times) + + + (%1$d minute) + (%1$d minutes) + + + (%1$d second) + (%1$d seconds) + + Backspace Send a whisper Whispering @%1$s diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 9ccf42cb6..b44021a8f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -468,6 +468,60 @@ %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%1$s%2$s%3$s.%4$s + %1$s timed out %2$s%3$s.%4$s + %1$s has been timed out%2$s.%3$s + You were banned%1$s%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%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%3$s. + A message from %1$s was deleted%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.%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.%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%3$s in %4$s.%5$s + %1$s untimedout %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%4$s + for %1$s + by %1$s + : \"%1$s\" + : %1$s + saying: \"%1$s\" + + (%1$d time) + (%1$d times) + + + (%1$d minute) + (%1$d minutes) + + + (%1$d second) + (%1$d seconds) + + Backspace Send a whisper diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index a51592b3f..26401aa04 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -478,6 +478,67 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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%1$s%2$s%3$s.%4$s + %1$s expulsó temporalmente a %2$s%3$s.%4$s + %1$s ha sido expulsado temporalmente%2$s.%3$s + + Fuiste baneado%1$s%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%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%3$s. + Un mensaje de %1$s fue eliminado%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.%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.%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%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%4$s. + %1$s desbaneó a %2$s en %3$s. + %1$s eliminó un mensaje de %2$s en %3$s%4$s + + por %1$s + por %1$s + : \"%1$s\" + : %1$s + diciendo: \"%1$s\" + + (%1$d vez) + (%1$d veces) + + + (%1$d minuto) + (%1$d minutos) + + + (%1$d segundo) + (%1$d segundos) + + Retroceso Enviar un susurro diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index bf668cf8b..26ed5b856 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -312,6 +312,60 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %1$s poisti %2$s estettynä terminä AutoModista. %1$s poisti %2$s sallittuna terminä AutoModista. + + + Sinut asetettiin jäähylle%1$s%2$s%3$s.%4$s + %1$s asetti jäähylle käyttäjän %2$s%3$s.%4$s + %1$s on asetettu jäähylle%2$s.%3$s + Sinut estettiin%1$s%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%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%3$s. + Viesti käyttäjältä %1$s poistettiin%2$s. + %1$s tyhjesi chatin. + Moderaattori tyhjentsi 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.%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.%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 jäähylle käyttäjän %2$s%3$s 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%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%4$s + %1$s ajaksi + käyttäjän %1$s toimesta + : \"%1$s\" + : %1$s + sanoen: \"%1$s\" + + (%1$d kerta) + (%1$d kertaa) + + + (%1$d minuutti) + (%1$d minuuttia) + + + (%1$d sekunti) + (%1$d sekuntia) + + Askelpalautin Lähetä kuiskaus diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 4169ce185..da4f69721 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -462,6 +462,67 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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%1$s%2$s%3$s.%4$s + %1$s a exclu temporairement %2$s%3$s.%4$s + %1$s a été exclu temporairement%2$s.%3$s + + Vous avez été banni%1$s%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%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%3$s. + Un message de %1$s a été supprimé%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.%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.%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%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%4$s. + %1$s a débanni %2$s dans %3$s. + %1$s a supprimé un message de %2$s dans %3$s%4$s + + pour %1$s + par %1$s + : \"%1$s\" + : %1$s + disant : \"%1$s\" + + (%1$d fois) + (%1$d fois) + + + (%1$d minute) + (%1$d minutes) + + + (%1$d seconde) + (%1$d secondes) + + Retour arrière Envoyer un chuchotement diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index f6a0987f7..642fddaec 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -453,6 +453,60 @@ %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%2$s%3$s.%4$s + %1$s ideiglenesen kitiltotta %2$s felhasználót%3$s.%4$s + %1$s ideiglenesen ki lett tiltva%2$s.%3$s + Ki lettél tiltva%1$s%2$s. + %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%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%3$s. + %1$s üzenete törölve lett%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.%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.%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 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%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%4$s + %1$s időtartamra + %1$s által + : \"%1$s\" + : %1$s + mondván: \"%1$s\" + + (%1$d alkalommal) + (%1$d alkalommal) + + + (%1$d perc) + (%1$d perc) + + + (%1$d másodperc) + (%1$d másodperc) + + Törlés Suttogás küldése diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6ea440624..65bcf45af 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -445,6 +445,67 @@ %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%1$s%2$s%3$s.%4$s + %1$s ha espulso temporaneamente %2$s%3$s.%4$s + %1$s è stato espulso temporaneamente%2$s.%3$s + + Sei stato bannato%1$s%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%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%3$s. + Un messaggio di %1$s è stato eliminato%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.%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.%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%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%4$s. + %1$s ha sbannato %2$s in %3$s. + %1$s ha eliminato un messaggio di %2$s in %3$s%4$s + + per %1$s + da %1$s + : \"%1$s\" + : %1$s + dicendo: \"%1$s\" + + (%1$d volta) + (%1$d volte) + + + (%1$d minuto) + (%1$d minuti) + + + (%1$d secondo) + (%1$d secondi) + + Cancella Invia un sussurro diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 2baa0a3c1..994df8daf 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -440,6 +440,57 @@ %1$sがAutoModから%2$sをブロック用語として削除しました。 %1$sがAutoModから%2$sを許可用語として削除しました。 + + + あなたは%1$s%2$s%3$sタイムアウトされました。%4$s + %1$sが%2$sを%3$sタイムアウトしました。%4$s + %1$sは%2$sタイムアウトされました。%3$s + あなたは%1$s%2$sBANされました。 + %1$sが%2$sを%3$sBANしました。 + %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に警告しました%3$s + %1$sが%2$sへのレイドを開始しました。 + %1$sが%2$sへのレイドをキャンセルしました。 + %1$sが%2$sのメッセージを削除しました%3$s。 + %1$sのメッセージが削除されました%2$s。 + %1$sがチャットを消去しました。 + モデレーターによってチャットが消去されました。 + %1$sがemote-only modeをオンにしました。 + %1$sがemote-only modeをオフにしました。 + %1$sがfollowers-only modeをオンにしました。%2$s + %1$sがfollowers-only modeをオフにしました。 + %1$sがunique-chat modeをオンにしました。 + %1$sがunique-chat modeをオフにしました。 + %1$sがスローモードをオンにしました。%2$s + %1$sがスローモードをオフにしました。 + %1$sがsubscribers-only modeをオンにしました。 + %1$sがsubscribers-only modeをオフにしました。 + %1$sが%4$sで%2$sを%3$sタイムアウトしました。%5$s + %1$sが%3$sで%2$sのタイムアウトを解除しました。 + %1$sが%3$sで%2$sをBANしました%4$s。 + %1$sが%3$sで%2$sのBANを解除しました。 + %1$sが%3$sで%2$sのメッセージを削除しました%4$s + %1$s間 + %1$sにより + : \"%1$s\" + : %1$s + 内容: \"%1$s\" + + (%1$d回) + + + (%1$d分) + + + (%1$d秒) + + バックスペース ウィスパーを送信 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index d01ad080e..41a76efa1 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -480,6 +480,66 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %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%1$s%2$s%3$s.%4$s + %1$s wyciszył/a %2$s%3$s.%4$s + %1$s został/a wyciszony/a%2$s.%3$s + Zostałeś/aś zbanowany/a%1$s%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%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%3$s. + Wiadomość od %1$s została usunięta%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.%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.%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%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%4$s. + %1$s odbanował/a %2$s w %3$s. + %1$s usunął/ęła wiadomość od %2$s w %3$s%4$s + na %1$s + przez %1$s + : \"%1$s\" + : %1$s + mówiąc: \"%1$s\" + + (%1$d raz) + (%1$d razy) + (%1$d razy) + (%1$d razy) + + + (%1$d minuta) + (%1$d minuty) + (%1$d minut) + (%1$d minut) + + + (%1$d sekunda) + (%1$d sekundy) + (%1$d sekund) + (%1$d sekund) + + Cofnij Wyślij szept diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a9bf60635..538aea76a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -457,6 +457,63 @@ %1$s removeu %2$s como termo bloqueado do AutoMod. %1$s removeu %2$s como termo permitido do AutoMod. + + + Você foi suspenso%1$s%2$s%3$s.%4$s + %1$s suspendeu %2$s%3$s.%4$s + %1$s foi suspenso%2$s.%3$s + Você foi banido%1$s%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%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%3$s. + Uma mensagem de %1$s foi excluída%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.%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.%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%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%4$s. + %1$s desbaniu %2$s em %3$s. + %1$s excluiu a mensagem de %2$s em %3$s%4$s + por %1$s + por %1$s + : \"%1$s\" + : %1$s + dizendo: \"%1$s\" + + (%1$d vez) + (%1$d vezes) + (%1$d vezes) + + + (%1$d minuto) + (%1$d minutos) + (%1$d minutos) + + + (%1$d segundo) + (%1$d segundos) + (%1$d segundos) + + Apagar Enviar um sussurro diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index beb8ca33a..d3c668ca9 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -447,6 +447,63 @@ %1$s removeu %2$s como termo bloqueado do AutoMod. %1$s removeu %2$s como termo permitido do AutoMod. + + + Foste suspenso%1$s%2$s%3$s.%4$s + %1$s suspendeu %2$s%3$s.%4$s + %1$s foi suspenso%2$s.%3$s + Foste banido%1$s%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%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%3$s. + Uma mensagem de %1$s foi eliminada%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.%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.%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%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%4$s. + %1$s desbaniu %2$s em %3$s. + %1$s eliminou a mensagem de %2$s em %3$s%4$s + por %1$s + por %1$s + : \"%1$s\" + : %1$s + a dizer: \"%1$s\" + + (%1$d vez) + (%1$d vezes) + (%1$d vezes) + + + (%1$d minuto) + (%1$d minutos) + (%1$d minutos) + + + (%1$d segundo) + (%1$d segundos) + (%1$d segundos) + + Apagar Enviar um sussurro diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index b38d98072..66208182c 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -466,6 +466,66 @@ %1$s удалил %2$s как заблокированный термин из AutoMod. %1$s удалил %2$s как разрешённый термин из AutoMod. + + + Вы были заглушены%1$s%2$s%3$s.%4$s + %1$s заглушил %2$s%3$s.%4$s + %1$s был заглушён%2$s.%3$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%3$s + %1$s начал рейд на %2$s. + %1$s отменил рейд на %2$s. + %1$s удалил сообщение от %2$s%3$s. + Сообщение от %1$s было удалено%2$s. + %1$s очистил чат. + Чат был очищен модератором. + %1$s включил режим только эмоции. + %1$s выключил режим только эмоции. + %1$s включил режим только для подписчиков канала.%2$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.%5$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%4$s + на %1$s + от %1$s + : \"%1$s\" + : %1$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 секунд) + + Удалить Отправить личное сообщение diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 8c997a99b..f5632fffc 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -254,6 +254,63 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%1$s је уклонио %2$s као блокирани термин са AutoMod. %1$s је уклонио %2$s као дозвољени термин са AutoMod. + + + Добили сте тајмаут%1$s%2$s%3$s.%4$s + %1$s је дао тајмаут кориснику %2$s%3$s.%4$s + %1$s је добио тајмаут%2$s.%3$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%3$s + %1$s је покренуо рејд на %2$s. + %1$s је отказао рејд на %2$s. + %1$s је обрисао поруку од %2$s%3$s. + Порука од %1$s је обрисана%2$s. + %1$s је очистио чат. + Чат је очишћен од стране модератора. + %1$s је укључио emote-only режим. + %1$s је искључио emote-only режим. + %1$s је укључио followers-only режим.%2$s + %1$s је искључио followers-only режим. + %1$s је укључио unique-chat режим. + %1$s је искључио unique-chat режим. + %1$s је укључио спори режим.%2$s + %1$s је искључио спори режим. + %1$s је укључио subscribers-only режим. + %1$s је искључио subscribers-only режим. + %1$s је дао тајмаут кориснику %2$s%3$s у %4$s.%5$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%4$s + на %1$s + од стране %1$s + : \"%1$s\" + : %1$s + са садржајем: \"%1$s\" + + (%1$d пут) + (%1$d пута) + (%1$d пута) + + + (%1$d минут) + (%1$d минута) + (%1$d минута) + + + (%1$d секунда) + (%1$d секунде) + (%1$d секунди) + + Обриши Пошаљи шапат diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 1cd167b00..e26fcb524 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -474,6 +474,60 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %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ı. + + + Zaman aşımına uğratıldınız%1$s%2$s%3$s.%4$s + %1$s, %2$s kullanıcısını zaman aşımına uğrattı%3$s.%4$s + %1$s zaman aşımına uğratıldı%2$s.%3$s + Banlandınız%1$s%2$s. + %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ı%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%3$s. + %1$s kullanıcısının bir mesajı silindi%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ı.%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ı.%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 %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ı%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%4$s + %1$s süreliğine + %1$s tarafından + : \"%1$s\" + : %1$s + şunu diyerek: \"%1$s\" + + (%1$d kez) + (%1$d kez) + + + (%1$d dakika) + (%1$d dakika) + + + (%1$d saniye) + (%1$d saniye) + + Geri sil Fısıltı gönder diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 571db797d..e8c2ba9a2 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -463,6 +463,66 @@ %1$s видалив %2$s як заблокований термін з AutoMod. %1$s видалив %2$s як дозволений термін з AutoMod. + + + Вас було заглушено%1$s%2$s%3$s.%4$s + %1$s заглушив %2$s%3$s.%4$s + %1$s було заглушено%2$s.%3$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%3$s + %1$s розпочав рейд на %2$s. + %1$s скасував рейд на %2$s. + %1$s видалив повідомлення від %2$s%3$s. + Повідомлення від %1$s було видалено%2$s. + %1$s очистив чат. + Чат було очищено модератором. + %1$s увімкнув режим лише емоції. + %1$s вимкнув режим лише емоції. + %1$s увімкнув режим лише для підписників каналу.%2$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.%5$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%4$s + на %1$s + від %1$s + : \"%1$s\" + : %1$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 секунд) + + Видалити Надіслати шепіт diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb3bace09..740cfbeee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,7 +112,68 @@ %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. + %1$s removed %2$s as a permitted term on AutoMod. + + + + You were timed out%1$s%2$s%3$s.%4$s + %1$s timed out %2$s%3$s.%4$s + %1$s has been timed out%2$s.%3$s + + You were banned%1$s%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%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%3$s. + A message from %1$s was deleted%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.%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.%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%3$s in %4$s.%5$s + %1$s untimedout %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%4$s + + for %1$s + by %1$s + : \"%1$s\" + : %1$s + saying: \"%1$s\" + + (%1$d time) + (%1$d times) + + + (%1$d minute) + (%1$d minutes) + + + (%1$d second) + (%1$d seconds) + < Message deleted > Regex From 4b6a2479efb366bb03ebe35e4a62209396662e93 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 11:49:11 +0100 Subject: [PATCH 055/349] fix(compose): Use ImmutableList for Compose stability and fix build warning --- .../chat/compose/ChatMessageMapper.kt | 38 ++++---- .../chat/compose/ChatMessageUiState.kt | 15 +-- .../dankchat/chat/compose/TextResource.kt | 6 +- .../dankchat/data/repo/chat/ChatRepository.kt | 5 +- .../data/twitch/message/ModerationMessage.kt | 91 ++++++++++--------- .../flxrs/dankchat/main/compose/MainScreen.kt | 22 ++--- 6 files changed, 91 insertions(+), 86 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 96be6da6e..28f25625e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -28,6 +28,8 @@ import com.flxrs.dankchat.data.repo.chat.UsersRepository import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.utils.DateTimeUtils import com.google.android.material.color.MaterialColors +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import org.koin.core.annotation.Single /** @@ -161,23 +163,23 @@ class ChatMessageMapper( 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, listOf(type.status)) - is SystemMessageType.ChannelFFZEmotesFailed -> TextResource.Res(R.string.system_message_ffz_emotes_failed, listOf(type.status)) - is SystemMessageType.ChannelSevenTVEmotesFailed -> TextResource.Res(R.string.system_message_7tv_emotes_failed, listOf(type.status)) + 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 -> TextResource.Plain(type.message) 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, listOf(type.status)) + 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, listOf(type.actorName, type.emoteName)) - is SystemMessageType.ChannelSevenTVEmoteRemoved -> TextResource.Res(R.string.system_message_7tv_emote_removed, listOf(type.actorName, type.emoteName)) + 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, - listOf(type.actorName, type.oldEmoteName, type.emoteName) + persistentListOf(type.actorName, type.oldEmoteName, type.emoteName) ) - is SystemMessageType.ChannelSevenTVEmoteSetChanged -> TextResource.Res(R.string.system_message_7tv_emote_set_changed, listOf(type.actorName, type.newEmoteSetName)) + 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) { @@ -187,7 +189,7 @@ class ChatMessageMapper( 404 -> R.string.automod_error_not_found else -> R.string.automod_error_unknown } - TextResource.Res(errorResId, listOf(actionRes)) + TextResource.Res(errorResId, persistentListOf(actionRes)) } } @@ -321,7 +323,7 @@ class ChatMessageMapper( else -> null }, ) - }, + }.toImmutableList(), userDisplayName = userName.formatWithDisplayName(userDisplayName), rawNameColor = color, messageText = messageText, @@ -363,7 +365,7 @@ class ChatMessageMapper( 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 @@ -383,16 +385,16 @@ class ChatMessageMapper( val firstEmote = emoteGroup.first() EmoteUi( code = firstEmote.code, - urls = emoteGroup.map { it.url }, + urls = emoteGroup.map { it.url }.toImmutableList(), position = position, isAnimated = hasAnimated, isTwitch = emoteGroup.any { it.isTwitch }, scale = firstEmote.scale, - emotes = emoteGroup, + emotes = emoteGroup.toImmutableList(), cheerAmount = firstEmote.cheerAmount, cheerColor = firstEmote.cheerColor?.let { Color(it) }, ) - } + }.toImmutableList() val threadUi = if (thread != null && !isInReplies) { thread.toThreadUi() @@ -481,7 +483,7 @@ class ChatMessageMapper( badge = badge, position = index ) - } + }.toImmutableList() val emoteUis = emotes.groupBy { it.position }.map { (position, emoteGroup) -> // Check if any emote in the group is animated @@ -501,16 +503,16 @@ class ChatMessageMapper( val firstEmote = emoteGroup.first() EmoteUi( code = firstEmote.code, - urls = emoteGroup.map { it.url }, + urls = emoteGroup.map { it.url }.toImmutableList(), position = position, isAnimated = hasAnimated, isTwitch = emoteGroup.any { it.isTwitch }, scale = firstEmote.scale, - emotes = emoteGroup, + emotes = emoteGroup.toImmutableList(), cheerAmount = firstEmote.cheerAmount, cheerColor = firstEmote.cheerColor?.let { Color(it) }, ) - } + }.toImmutableList() val fullMessage = buildString { if (timestamp.isNotEmpty()) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 79952613b..f9107b871 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -9,6 +9,7 @@ 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.data.twitch.message.MessageThreadHeader +import kotlinx.collections.immutable.ImmutableList /** * UI state for rendering chat messages in Compose. @@ -40,11 +41,11 @@ sealed interface ChatMessageUiState { val userId: UserId?, val userName: UserName, val displayName: DisplayName, - val badges: List, + val badges: ImmutableList, val rawNameColor: Int, val nameText: String, val message: String, - val emotes: List, + val emotes: ImmutableList, val isAction: Boolean, val thread: ThreadUi?, val fullMessage: String, // For copying @@ -161,7 +162,7 @@ sealed interface ChatMessageUiState { override val enableRipple: Boolean = false, val heldMessageId: String, val channel: UserName, - val badges: List, + val badges: ImmutableList, val userDisplayName: String, val rawNameColor: Int, val messageText: String, @@ -186,13 +187,13 @@ sealed interface ChatMessageUiState { val userId: UserId, val userName: UserName, val displayName: DisplayName, - val badges: List, + val badges: ImmutableList, val rawSenderColor: Int, val rawRecipientColor: Int, val senderName: String, val recipientName: String, val message: String, - val emotes: List, + val emotes: ImmutableList, val fullMessage: String, val replyTargetName: UserName, ) : ChatMessageUiState @@ -215,12 +216,12 @@ data class BadgeUi( @Immutable data class EmoteUi( val code: String, - val urls: List, + val urls: ImmutableList, val position: IntRange, val isAnimated: Boolean, val isTwitch: Boolean, val scale: Int, - val emotes: List, // For click handling + val emotes: ImmutableList, // For click handling val cheerAmount: Int? = null, val cheerColor: Color? = null, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt index 54f83560f..19ca46a08 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt @@ -6,6 +6,8 @@ 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 { @@ -13,10 +15,10 @@ sealed interface TextResource { data class Plain(val value: String) : TextResource @Immutable - data class Res(@StringRes val id: Int, val args: List = emptyList()) : TextResource + data class Res(@StringRes val id: Int, val args: ImmutableList = persistentListOf()) : TextResource @Immutable - data class PluralRes(@PluralsRes val id: Int, val quantity: Int, val args: List = emptyList()) : TextResource + data class PluralRes(@PluralsRes val id: Int, val quantity: Int, val args: ImmutableList = persistentListOf()) : TextResource } @Composable 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 ecc7e789f..3e9de7af9 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 @@ -36,6 +36,7 @@ import com.flxrs.dankchat.data.twitch.badge.Badge import com.flxrs.dankchat.data.twitch.badge.BadgeType import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.TextResource +import kotlinx.collections.immutable.persistentListOf import com.flxrs.dankchat.data.twitch.message.AutomodMessage import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.ModerationMessage @@ -981,7 +982,7 @@ class ChatRepository( blockedTerm: BlockedTermReasonDto?, messageText: String, ): TextResource = when { - reason == "automod" && automod != null -> TextResource.Res(R.string.automod_reason_category, listOf(automod.category, automod.level)) + 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 @@ -989,7 +990,7 @@ class ChatRepository( "\"${messageText.substring(start, end)}\"" } val count = blockedTerm.termsFound.size - TextResource.PluralRes(R.plurals.automod_reason_blocked_terms, count, listOf(count, terms)) + TextResource.PluralRes(R.plurals.automod_reason_blocked_terms, count, persistentListOf(count, terms)) } else -> TextResource.Plain(reason) 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 89663fcc5..91e6313f6 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 @@ -2,6 +2,7 @@ package com.flxrs.dankchat.data.twitch.message import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.TextResource +import kotlinx.collections.immutable.persistentListOf import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelModerateAction @@ -71,13 +72,13 @@ data class ModerationMessage( } private val durationSuffix: TextResource - get() = duration?.let { TextResource.Res(R.string.mod_duration_suffix, listOf(it)) } ?: TextResource.Plain("") + get() = duration?.let { TextResource.Res(R.string.mod_duration_suffix, persistentListOf(it)) } ?: TextResource.Plain("") private val creatorSuffix: TextResource - get() = creatorUserDisplay?.let { TextResource.Res(R.string.mod_by_creator_suffix, listOf(it.toString())) } ?: TextResource.Plain("") + get() = creatorUserDisplay?.let { TextResource.Res(R.string.mod_by_creator_suffix, persistentListOf(it.toString())) } ?: TextResource.Plain("") private val quotedReasonSuffix: TextResource - get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reason_suffix, listOf(it)) } ?: TextResource.Plain("") + get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reason_suffix, persistentListOf(it)) } ?: TextResource.Plain("") private val reasonsSuffix: TextResource - get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reasons_suffix, listOf(it)) } ?: TextResource.Plain("") + get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reasons_suffix, persistentListOf(it)) } ?: TextResource.Plain("") private val quotedTermsOrBlank get() = reason.takeUnless { it.isNullOrBlank() } ?: "terms" private fun sayingSuffix(showDeletedMessage: Boolean): TextResource { @@ -88,86 +89,86 @@ data class ModerationMessage( fullReason.length > 50 -> "${fullReason.take(50)}…" else -> fullReason } - return TextResource.Res(R.string.mod_saying_suffix, listOf(trimmed)) + return TextResource.Res(R.string.mod_saying_suffix, persistentListOf(trimmed)) } private fun countSuffix(): TextResource { return when { - stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, listOf(stackCount)) + stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) else -> TextResource.Plain("") } } private fun minutesSuffix(): TextResource { - return durationInt?.takeIf { it > 0 }?.let { TextResource.PluralRes(R.plurals.mod_minutes_suffix, it, listOf(it)) } ?: TextResource.Plain("") + return durationInt?.takeIf { it > 0 }?.let { TextResource.PluralRes(R.plurals.mod_minutes_suffix, it, persistentListOf(it)) } ?: TextResource.Plain("") } private fun secondsSuffix(): TextResource { - return durationInt?.let { TextResource.PluralRes(R.plurals.mod_seconds_suffix, it, listOf(it)) } ?: TextResource.Plain("") + return durationInt?.let { TextResource.PluralRes(R.plurals.mod_seconds_suffix, it, persistentListOf(it)) } ?: TextResource.Plain("") } fun getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): TextResource { return when (action) { Action.Timeout -> when (targetUser) { - currentUser -> TextResource.Res(R.string.mod_timeout_self, listOf(durationSuffix, creatorSuffix, quotedReasonSuffix, countSuffix())) + currentUser -> TextResource.Res(R.string.mod_timeout_self, persistentListOf(durationSuffix, creatorSuffix, quotedReasonSuffix, countSuffix())) else -> when (creatorUserDisplay) { - null -> TextResource.Res(R.string.mod_timeout_no_creator, listOf(targetUserDisplay.toString(), durationSuffix, countSuffix())) - else -> TextResource.Res(R.string.mod_timeout_by_creator, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, countSuffix())) + null -> TextResource.Res(R.string.mod_timeout_no_creator, persistentListOf(targetUserDisplay.toString(), durationSuffix, countSuffix())) + else -> TextResource.Res(R.string.mod_timeout_by_creator, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, countSuffix())) } } - Action.Untimeout -> TextResource.Res(R.string.mod_untimeout, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Untimeout -> TextResource.Res(R.string.mod_untimeout, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) Action.Ban -> when (targetUser) { - currentUser -> TextResource.Res(R.string.mod_ban_self, listOf(creatorSuffix, quotedReasonSuffix)) + currentUser -> TextResource.Res(R.string.mod_ban_self, persistentListOf(creatorSuffix, quotedReasonSuffix)) else -> when (creatorUserDisplay) { - null -> TextResource.Res(R.string.mod_ban_no_creator, listOf(targetUserDisplay.toString())) - else -> TextResource.Res(R.string.mod_ban_by_creator, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), quotedReasonSuffix)) + null -> TextResource.Res(R.string.mod_ban_no_creator, persistentListOf(targetUserDisplay.toString())) + else -> TextResource.Res(R.string.mod_ban_by_creator, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), quotedReasonSuffix)) } } - Action.Unban -> TextResource.Res(R.string.mod_unban, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Mod -> TextResource.Res(R.string.mod_modded, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unmod -> TextResource.Res(R.string.mod_unmodded, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unban -> TextResource.Res(R.string.mod_unban, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Mod -> TextResource.Res(R.string.mod_modded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unmod -> TextResource.Res(R.string.mod_unmodded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) Action.Delete -> when (creatorUserDisplay) { - null -> TextResource.Res(R.string.mod_delete_no_creator, listOf(targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) - else -> TextResource.Res(R.string.mod_delete_by_creator, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) + null -> TextResource.Res(R.string.mod_delete_no_creator, persistentListOf(targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) + else -> TextResource.Res(R.string.mod_delete_by_creator, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) } Action.Clear -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_clear_no_creator) - else -> TextResource.Res(R.string.mod_clear_by_creator, listOf(creatorUserDisplay.toString())) + else -> TextResource.Res(R.string.mod_clear_by_creator, persistentListOf(creatorUserDisplay.toString())) } - Action.Vip -> TextResource.Res(R.string.mod_vip_added, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unvip -> TextResource.Res(R.string.mod_vip_removed, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Vip -> TextResource.Res(R.string.mod_vip_added, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unvip -> TextResource.Res(R.string.mod_vip_removed, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) Action.Warn -> { val suffix = when (val r = reasonsSuffix) { is TextResource.Plain -> TextResource.Plain(".") else -> r } - TextResource.Res(R.string.mod_warn, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), suffix)) + TextResource.Res(R.string.mod_warn, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), suffix)) } - Action.Raid -> TextResource.Res(R.string.mod_raid, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unraid -> TextResource.Res(R.string.mod_unraid, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.EmoteOnly -> TextResource.Res(R.string.mod_emote_only_on, listOf(creatorUserDisplay.toString())) - Action.EmoteOnlyOff -> TextResource.Res(R.string.mod_emote_only_off, listOf(creatorUserDisplay.toString())) - Action.Followers -> TextResource.Res(R.string.mod_followers_on, listOf(creatorUserDisplay.toString(), minutesSuffix())) - Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, listOf(creatorUserDisplay.toString())) - Action.UniqueChat -> TextResource.Res(R.string.mod_unique_chat_on, listOf(creatorUserDisplay.toString())) - Action.UniqueChatOff -> TextResource.Res(R.string.mod_unique_chat_off, listOf(creatorUserDisplay.toString())) - Action.Slow -> TextResource.Res(R.string.mod_slow_on, listOf(creatorUserDisplay.toString(), secondsSuffix())) - Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, listOf(creatorUserDisplay.toString())) - Action.Subscribers -> TextResource.Res(R.string.mod_subscribers_on, listOf(creatorUserDisplay.toString())) - Action.SubscribersOff -> TextResource.Res(R.string.mod_subscribers_off, listOf(creatorUserDisplay.toString())) - Action.SharedTimeout -> TextResource.Res(R.string.mod_shared_timeout, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, sourceBroadcasterDisplay.toString(), countSuffix())) - Action.SharedUntimeout -> TextResource.Res(R.string.mod_shared_untimeout, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) - Action.SharedBan -> TextResource.Res(R.string.mod_shared_ban, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), quotedReasonSuffix)) - Action.SharedUnban -> TextResource.Res(R.string.mod_shared_unban, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) - Action.SharedDelete -> TextResource.Res(R.string.mod_shared_delete, listOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), sayingSuffix(showDeletedMessage))) - Action.AddBlockedTerm -> TextResource.Res(R.string.automod_moderation_added_blocked_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.AddPermittedTerm -> TextResource.Res(R.string.automod_moderation_added_permitted_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.RemoveBlockedTerm -> TextResource.Res(R.string.automod_moderation_removed_blocked_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.RemovePermittedTerm -> TextResource.Res(R.string.automod_moderation_removed_permitted_term, listOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.Raid -> TextResource.Res(R.string.mod_raid, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unraid -> TextResource.Res(R.string.mod_unraid, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.EmoteOnly -> TextResource.Res(R.string.mod_emote_only_on, persistentListOf(creatorUserDisplay.toString())) + Action.EmoteOnlyOff -> TextResource.Res(R.string.mod_emote_only_off, persistentListOf(creatorUserDisplay.toString())) + Action.Followers -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creatorUserDisplay.toString(), minutesSuffix())) + Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, persistentListOf(creatorUserDisplay.toString())) + Action.UniqueChat -> TextResource.Res(R.string.mod_unique_chat_on, persistentListOf(creatorUserDisplay.toString())) + Action.UniqueChatOff -> TextResource.Res(R.string.mod_unique_chat_off, persistentListOf(creatorUserDisplay.toString())) + Action.Slow -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creatorUserDisplay.toString(), secondsSuffix())) + Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, persistentListOf(creatorUserDisplay.toString())) + Action.Subscribers -> TextResource.Res(R.string.mod_subscribers_on, persistentListOf(creatorUserDisplay.toString())) + Action.SubscribersOff -> TextResource.Res(R.string.mod_subscribers_off, persistentListOf(creatorUserDisplay.toString())) + Action.SharedTimeout -> TextResource.Res(R.string.mod_shared_timeout, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, sourceBroadcasterDisplay.toString(), countSuffix())) + Action.SharedUntimeout -> TextResource.Res(R.string.mod_shared_untimeout, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) + Action.SharedBan -> TextResource.Res(R.string.mod_shared_ban, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), quotedReasonSuffix)) + Action.SharedUnban -> TextResource.Res(R.string.mod_shared_unban, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) + Action.SharedDelete -> TextResource.Res(R.string.mod_shared_delete, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), sayingSuffix(showDeletedMessage))) + Action.AddBlockedTerm -> TextResource.Res(R.string.automod_moderation_added_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.AddPermittedTerm -> TextResource.Res(R.string.automod_moderation_added_permitted_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.RemoveBlockedTerm -> TextResource.Res(R.string.automod_moderation_removed_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.RemovePermittedTerm -> TextResource.Res(R.string.automod_moderation_removed_permitted_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 3ec341e8b..0494bf0f8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -890,18 +890,16 @@ fun MainScreen( .weight(splitFraction) .fillMaxSize() ) { - currentStream?.let { channel -> - StreamView( - channel = channel, - streamViewModel = streamViewModel, - fillPane = true, - onClose = { - focusManager.clearFocus() - streamViewModel.closeStream() - }, - modifier = Modifier.fillMaxSize() - ) - } + StreamView( + channel = currentStream, + streamViewModel = streamViewModel, + fillPane = true, + onClose = { + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = Modifier.fillMaxSize() + ) } // Right pane: Chat + all overlays From 2c644907ba7bb44ffc413d59c129777129854604 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 11:56:37 +0100 Subject: [PATCH 056/349] fix(compose): Use mutableFloatStateOf to avoid autoboxing --- .../kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt | 3 ++- .../main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index 0c8a3a9a3..fbe62599d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -56,6 +56,7 @@ import androidx.compose.material3.TooltipState 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.derivedStateOf import androidx.compose.runtime.remember @@ -204,7 +205,7 @@ fun FloatingToolbar( ) { val scrimColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) val statusBarPx = with(density) { WindowInsets.statusBars.getTop(density).toFloat() } - var toolbarRowHeight by remember { mutableStateOf(0f) } + var toolbarRowHeight by remember { mutableFloatStateOf(0f) } val scrimModifier = if (hasStream) { Modifier.fillMaxWidth() } else { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 0494bf0f8..46c540351 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -220,7 +220,7 @@ fun MainScreen( val inputState by chatInputViewModel.uiState(sheetNavigationViewModel.fullScreenSheetState, mentionViewModel.currentTab).collectAsStateWithLifecycle() val isKeyboardVisible = isImeVisible || isImeOpening - var backProgress by remember { mutableStateOf(0f) } + var backProgress by remember { mutableFloatStateOf(0f) } // Stream state val streamVmState by streamViewModel.streamState.collectAsStateWithLifecycle() From 0a0781c67fb1f868633af678a79c6fc3d662cfaa Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 12:12:40 +0100 Subject: [PATCH 057/349] fix: Resolve lint errors and warnings across the migration branch --- app/build.gradle.kts | 4 ++++ .../kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt | 3 ++- app/src/main/res/values-ca/strings.xml | 3 +++ app/src/main/res/values-es-rES/strings.xml | 3 +++ app/src/main/res/values-fr-rFR/strings.xml | 3 +++ app/src/main/res/values-it/strings.xml | 3 +++ app/src/main/res/values-ru-rRU/strings.xml | 2 +- 7 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7bbefa60c..c32720774 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + lint { + disable += "RestrictedApi" + } + //noinspection WrongGradleMethod androidComponents { beforeVariants { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt index 826064c96..b92d8f211 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt @@ -5,6 +5,7 @@ import android.content.ClipboardManager import android.content.Intent import android.net.Uri import android.os.Bundle +import com.flxrs.dankchat.utils.extensions.parcelable import android.webkit.MimeTypeMap import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -79,7 +80,7 @@ class ShareUploadActivity : ComponentActivity() { } private fun handleShareIntent(intent: Intent) { - val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + val uri = intent.parcelable(Intent.EXTRA_STREAM) if (uri == null) { uploadState = ShareUploadState.Error(getString(R.string.snackbar_upload_failed)) return diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index aceeb24a7..734c296f7 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -399,14 +399,17 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader dient: \"%1$s\" (%1$d vegada) + (%1$d vegades) (%1$d vegades) (%1$d minut) + (%1$d minuts) (%1$d minuts) (%1$d segon) + (%1$d segons) (%1$d segons) diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 26401aa04..98053514d 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -528,14 +528,17 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/ diciendo: \"%1$s\" (%1$d vez) + (%1$d veces) (%1$d veces) (%1$d minuto) + (%1$d minutos) (%1$d minutos) (%1$d segundo) + (%1$d segundos) (%1$d segundos) diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index da4f69721..0734248a1 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -512,14 +512,17 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les disant : \"%1$s\" (%1$d fois) + (%1$d fois) (%1$d fois) (%1$d minute) + (%1$d minutes) (%1$d minutes) (%1$d seconde) + (%1$d secondes) (%1$d secondes) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 65bcf45af..d4bec0f17 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -495,14 +495,17 @@ dicendo: \"%1$s\" (%1$d volta) + (%1$d volte) (%1$d volte) (%1$d minuto) + (%1$d minuti) (%1$d minuti) (%1$d secondo) + (%1$d secondi) (%1$d secondi) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 66208182c..467c88f0d 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -351,7 +351,7 @@ Пользовательское отображение пользователя Удалить пользовательское отображение пользователя Псевдоним - Пользовательский цвет + Пользовательский цвет Пользовательский псевдоним Выбрать пользовательский цвет Добавить пользовательское имя и цвет для пользователей From c8ee57d90632aba019258e2f93444e7016883e04 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 14:04:29 +0100 Subject: [PATCH 058/349] feat(build): Migrate to AGP 9.1.0 and JDK 21 --- app/build.gradle.kts | 65 +++++++++++-------- app/src/main/AndroidManifest.xml | 2 + .../dankchat/chat/compose/TextResource.kt | 4 +- .../dankchat/chat/suggestion/Suggestion.kt | 2 +- .../chat/user/compose/UserPopupDialog.kt | 3 +- .../data/repo/HighlightsRepository.kt | 16 ++--- .../data/twitch/emote/CheermoteSet.kt | 2 +- .../dankchat/login/compose/LoginScreen.kt | 30 ++++----- .../com/flxrs/dankchat/main/MainActivity.kt | 1 - .../dankchat/main/TabSelectionListener.kt | 47 -------------- .../dankchat/main/compose/FloatingToolbar.kt | 1 + .../dankchat/utils/extensions/Extensions.kt | 16 ----- build.gradle.kts | 7 +- gradle.properties | 4 -- gradle/gradle-daemon-jvm.properties | 12 ++++ gradle/libs.versions.toml | 3 +- settings.gradle.kts | 5 +- 17 files changed, 90 insertions(+), 130 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/TabSelectionListener.kt create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c32720774..d4d6dede7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,14 +1,12 @@ -@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) @@ -42,11 +40,6 @@ android { } } - sourceSets { - getByName("main") { - java.srcDir("src/main/kotlin") - } - } buildFeatures { viewBinding = true buildConfig = true @@ -80,31 +73,27 @@ android { } } - 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 } lint { disable += "RestrictedApi" } - - //noinspection WrongGradleMethod - androidComponents { - beforeVariants { - sourceSets.named("main") { - java.srcDir(File("build/generated/ksp/${it.name}/kotlin")) - } - } - } } ksp { @@ -119,9 +108,9 @@ tasks.withType { } 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", @@ -241,3 +230,27 @@ fun gradleLocalProperties(projectRootDir: File, providers: ProviderFactory): Pro 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/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fb01be804..cf81f8806 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,8 @@ tools:ignore="AllowBackup,GoogleAppIndexingWarning" tools:targetApi="tiramisu"> + + = persistentListOf()) : TextResource + data class Res(@param:StringRes val id: Int, val args: ImmutableList = persistentListOf()) : TextResource @Immutable - data class PluralRes(@PluralsRes val id: Int, val quantity: Int, val args: ImmutableList = persistentListOf()) : TextResource + data class PluralRes(@param:PluralsRes val id: Int, val quantity: Int, val args: ImmutableList = persistentListOf()) : TextResource } @Composable 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 index b8367fb7f..924e0f722 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt @@ -17,7 +17,7 @@ sealed interface Suggestion { override fun toString() = command } - data class FilterSuggestion(val keyword: String, @StringRes val descriptionRes: Int, val displayText: String? = null) : Suggestion { + 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/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index 8a156715c..5e1693a2a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable @@ -255,7 +256,7 @@ private fun UserInfoSection( val title = badge.badge.title if (title != null) { TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { PlainTooltip { Text(title) 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..8917be161 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 @@ -44,7 +44,7 @@ class HighlightsRepository( private val userHighlightDao: UserHighlightDao, private val badgeHighlightDao: BadgeHighlightDao, private val blacklistedUserDao: BlacklistedUserDao, - private val preferences: DankChatPreferenceStore, + preferences: DankChatPreferenceStore, private val notificationsSettingsDataStore: NotificationsSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { @@ -428,13 +428,13 @@ class HighlightsRepository( 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 = "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/twitch/emote/CheermoteSet.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/CheermoteSet.kt index c0e1a41dd..5dcfe1d0a 100644 --- 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 @@ -10,7 +10,7 @@ data class CheermoteSet( data class CheermoteTier( val minBits: Int, - @ColorInt val color: Int, + @param:ColorInt val color: Int, val animatedUrl: String, val staticUrl: String, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt index 5a1f9aa2d..c0ef08c1f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt @@ -33,8 +33,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.net.toUri -import androidx.navigation.NavController import com.flxrs.dankchat.R import com.flxrs.dankchat.login.LoginViewModel import org.koin.compose.viewmodel.koinViewModel @@ -42,7 +40,6 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( - navController: NavController, onLoginSuccess: () -> Unit, onCancel: () -> Unit, ) { @@ -83,11 +80,13 @@ fun LoginScreen( ) } ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + Column(modifier = Modifier + .padding(paddingValues) + .fillMaxSize()) { if (isLoading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } - + AndroidView( factory = { context -> WebView(context).also { webViewRef = it }.apply { @@ -98,38 +97,31 @@ fun LoginScreen( @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?, url: String?): Boolean { - val urlString = url ?: "" - val fragment = urlString.toUri().fragment ?: return false - viewModel.parseToken(fragment) - return true // Consume - } - 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?) { Log.e("LoginScreen", "Error: ${error?.description}") isLoading = false } } - + loadUrl(viewModel.loginUrl) } }, @@ -137,8 +129,8 @@ fun LoginScreen( ) } } - + BackHandler { onCancel() } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index 0389b8c77..ef0acfd65 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -287,7 +287,6 @@ class MainActivity : AppCompatActivity() { popExitTransition = { fadeOut(animationSpec = tween(90)) } ) { LoginScreen( - navController = navController, onLoginSuccess = { navController.popBackStack() }, onCancel = { navController.popBackStack() } ) 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/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index fbe62599d..944daf24b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -431,6 +431,7 @@ fun FloatingToolbar( } TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, spacingBetweenTooltipAndAnchor = 8.dp, ), tooltip = { 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 310db8aed..834e3788f 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 @@ -4,16 +4,10 @@ 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 kotlinx.serialization.json.Json fun List?.toEmoteItems(): List = this @@ -67,16 +61,6 @@ inline fun Json.decodeOrNull(json: String): T? = runCatching { 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 diff --git a/build.gradle.kts b/build.gradle.kts index e4561a892..057be2a3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,11 @@ +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 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 eb0023b9a..8cce6209d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ koin = "4.1.1" koin-annotations = "2.3.1" about-libraries = "13.2.1" -androidGradlePlugin = "8.13.2" +androidGradlePlugin = "9.1.0" androidDesugarLibs = "2.1.5" androidxActivity = "1.13.0" androidxBrowser = "1.9.0" @@ -54,6 +54,7 @@ android-flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = 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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d69b7084..e66312d6e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("UnstableApiUsage") - pluginManagement { repositories { gradlePluginPortal() @@ -8,6 +6,9 @@ pluginManagement { maven(url = "https://jitpack.io") } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { From 7c53683316602f2f92cf149747009d439723fc84 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 14:18:29 +0100 Subject: [PATCH 059/349] fix: Fix hasPermission logic inversion, remove dead code, move constants to companion object --- .../chat/compose/ChatMessageMapper.kt | 67 ++-- .../com/flxrs/dankchat/main/MainActivity.kt | 35 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 323 +++++++++--------- .../dankchat/utils/extensions/Extensions.kt | 2 +- 4 files changed, 221 insertions(+), 206 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 28f25625e..3af5bc370 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -5,11 +5,13 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.ChatImportance import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.UsersRepository +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.Message 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.NoticeMessage import com.flxrs.dankchat.data.twitch.message.PointRedemptionMessage @@ -24,8 +26,6 @@ import com.flxrs.dankchat.data.twitch.message.senderAliasOrFormattedName import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettings import com.flxrs.dankchat.preferences.chat.ChatSettings -import com.flxrs.dankchat.data.repo.chat.UsersRepository -import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.utils.DateTimeUtils import com.google.android.material.color.MaterialColors import kotlinx.collections.immutable.persistentListOf @@ -41,32 +41,6 @@ class ChatMessageMapper( private val usersRepository: UsersRepository, ) { - // Highlight colors - Light theme - private val COLOR_SUB_HIGHLIGHT_LIGHT = Color(0xFFD1C4E9) - private val COLOR_MENTION_HIGHLIGHT_LIGHT = Color(0xFFEF9A9A) - private val COLOR_REDEMPTION_HIGHLIGHT_LIGHT = Color(0xFF93F1FF) - private val COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFC2F18D) - private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFFFE087) - // Highlight colors - Dark theme - private val COLOR_SUB_HIGHLIGHT_DARK = Color(0xFF543589) - private val COLOR_MENTION_HIGHLIGHT_DARK = Color(0xFF773031) - private val COLOR_REDEMPTION_HIGHLIGHT_DARK = Color(0xFF004F57) - private val COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK = Color(0xFF2D5000) - private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK = Color(0xFF574500) - // Checkered background colors - private val CHECKERED_LIGHT = Color( - android.graphics.Color.argb( - (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), - 0, 0, 0 - ) - ) - private val CHECKERED_DARK = Color( - android.graphics.Color.argb( - (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), - 255, 255, 255 - ) - ) - fun mapToUiState( item: ChatItem, appearanceSettings: AppearanceSettings, @@ -378,6 +352,7 @@ class ChatMessageMapper( is ChatMessageEmoteType.GlobalBTTVEmote -> true // Assume third-party can be animated is ChatMessageEmoteType.ChannelSevenTVEmote, is ChatMessageEmoteType.GlobalSevenTVEmote -> true + is ChatMessageEmoteType.Cheermote -> true } } @@ -494,8 +469,10 @@ class ChatMessageMapper( is ChatMessageEmoteType.GlobalFFZEmote, is ChatMessageEmoteType.ChannelBTTVEmote, is ChatMessageEmoteType.GlobalBTTVEmote -> true + is ChatMessageEmoteType.ChannelSevenTVEmote, is ChatMessageEmoteType.GlobalSevenTVEmote -> true + is ChatMessageEmoteType.Cheermote -> true } } @@ -607,4 +584,34 @@ class ChatMessageMapper( return getHighlightColors(highlight.type) } -} \ No newline at end of file + + companion object { + // Highlight colors - Light theme + private val COLOR_SUB_HIGHLIGHT_LIGHT = Color(0xFFD1C4E9) + private val COLOR_MENTION_HIGHLIGHT_LIGHT = Color(0xFFEF9A9A) + private val COLOR_REDEMPTION_HIGHLIGHT_LIGHT = Color(0xFF93F1FF) + private val COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFC2F18D) + private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFFFE087) + + // Highlight colors - Dark theme + private val COLOR_SUB_HIGHLIGHT_DARK = Color(0xFF543589) + private val COLOR_MENTION_HIGHLIGHT_DARK = Color(0xFF773031) + private val COLOR_REDEMPTION_HIGHLIGHT_DARK = Color(0xFF004F57) + private val COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK = Color(0xFF2D5000) + private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK = Color(0xFF574500) + + // Checkered background colors + private val CHECKERED_LIGHT = Color( + android.graphics.Color.argb( + (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), + 0, 0, 0 + ) + ) + private val CHECKERED_DARK = Color( + android.graphics.Color.argb( + (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), + 255, 255, 255 + ) + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt index ef0acfd65..e757bd5c0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt @@ -19,8 +19,6 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.FileProvider -import androidx.core.net.toFile import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -31,6 +29,8 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -44,11 +44,13 @@ import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R import com.flxrs.dankchat.changelog.ChangelogScreen import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.ApiException 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.login.compose.LoginScreen -import com.flxrs.dankchat.main.compose.MainScreen import com.flxrs.dankchat.main.compose.MainEventBus +import com.flxrs.dankchat.main.compose.MainScreen import com.flxrs.dankchat.onboarding.OnboardingDataStore import com.flxrs.dankchat.onboarding.OnboardingScreen import com.flxrs.dankchat.preferences.DankChatPreferenceStore @@ -68,15 +70,13 @@ 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.theme.DankChatTheme -import com.flxrs.dankchat.data.api.ApiException -import com.flxrs.dankchat.data.repo.data.DataRepository import com.flxrs.dankchat.utils.createMediaFile -import com.flxrs.dankchat.utils.removeExifAttributes 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 com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColorsOptions import kotlinx.coroutines.Dispatchers @@ -103,11 +103,11 @@ class MainActivity : AppCompatActivity() { } private val requestImageCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) handleCaptureRequest(imageCapture = true) + if (it.resultCode == RESULT_OK) handleCaptureRequest(imageCapture = true) } private val requestVideoCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) handleCaptureRequest(imageCapture = false) + if (it.resultCode == RESULT_OK) handleCaptureRequest(imageCapture = false) } private val requestGalleryMedia = registerForActivityResult(PickVisualMedia()) { uri -> @@ -333,14 +333,14 @@ class MainActivity : AppCompatActivity() { }, onNavigateRequested = { destination -> when (destination) { - SettingsNavigation.Appearance -> navController.navigate(AppearanceSettings) + 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) + 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) } } ) @@ -521,7 +521,7 @@ class MainActivity : AppCompatActivity() { } val hasCompletedOnboarding = onboardingDataStore.current().hasCompletedOnboarding - val needsNotificationPermission = hasCompletedOnboarding && isAtLeastTiramisu && hasPermission(Manifest.permission.POST_NOTIFICATIONS) + val needsNotificationPermission = hasCompletedOnboarding && isAtLeastTiramisu && !hasPermission(Manifest.permission.POST_NOTIFICATIONS) when { needsNotificationPermission -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) else -> startService() @@ -533,7 +533,7 @@ class MainActivity : AppCompatActivity() { try { isBound = true ContextCompat.startForegroundService(this, it) - bindService(it, twitchServiceConnection, Context.BIND_AUTO_CREATE) + bindService(it, twitchServiceConnection, BIND_AUTO_CREATE) } catch (t: Throwable) { Log.e(TAG, Log.getStackTraceString(t)) } @@ -641,7 +641,6 @@ class MainActivity : AppCompatActivity() { } } - private inner class TwitchServiceConnection : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { val binder = service as NotificationService.LocalBinder diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 46c540351..fd90300db 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -1,22 +1,21 @@ package com.flxrs.dankchat.main.compose +import android.app.Activity +import android.app.PictureInPictureParams +import android.os.Build +import android.util.Rational 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.ExperimentalFoundationApi -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.rememberTooltipState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.tween 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.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.Column @@ -25,7 +24,7 @@ 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.only +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -35,33 +34,32 @@ 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.windowInsetsPadding -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.systemGestures import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -75,36 +73,32 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.AbsoluteAlignment 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.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.max 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 kotlinx.coroutines.delay import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import android.app.Activity -import android.app.PictureInPictureParams -import android.os.Build -import android.util.Rational -import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.window.core.layout.WindowSizeClass import com.flxrs.dankchat.R -import androidx.compose.ui.input.nestedscroll.nestedScroll import com.flxrs.dankchat.chat.compose.ChatComposable import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker import com.flxrs.dankchat.chat.compose.overscrollRevealConnection @@ -118,21 +112,18 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.compose.sheets.EmoteMenu import com.flxrs.dankchat.onboarding.OnboardingDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore -import com.flxrs.dankchat.tour.FeatureTourController import com.flxrs.dankchat.tour.PostOnboardingStep import com.flxrs.dankchat.tour.TourStep import com.flxrs.dankchat.tour.rememberFeatureTourController import com.flxrs.dankchat.tour.rememberPostOnboardingCoordinator -import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding -import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.ui.platform.LocalResources -import androidx.window.core.layout.WindowSizeClass +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -315,14 +306,16 @@ fun MainScreen( // Tooltip .show() calls live in FloatingToolbar. LaunchedEffect(postOnboardingStep) { when (postOnboardingStep) { - PostOnboardingStep.FeatureTour -> { + PostOnboardingStep.FeatureTour -> { toolbarAddChannelTooltipState.dismiss() tourController.start() } + PostOnboardingStep.Complete, PostOnboardingStep.Idle -> { toolbarAddChannelTooltipState.dismiss() } - PostOnboardingStep.ToolbarPlusHint -> Unit + + PostOnboardingStep.ToolbarPlusHint -> Unit } } @@ -479,8 +472,6 @@ fun MainScreen( } } - val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route - val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() val composePagerState = rememberPagerState( @@ -534,9 +525,10 @@ fun MainScreen( } } - Box(modifier = Modifier - .fillMaxSize() - .then(if (!isFullscreen && !isInPipMode) Modifier.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) else Modifier) + Box( + modifier = Modifier + .fillMaxSize() + .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) { @@ -590,7 +582,9 @@ fun MainScreen( onToggleStream = { activeChannel?.let { streamViewModel.toggleStream(it) } }, onChangeRoomState = dialogViewModel::showRoomState, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, - onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, + onNewWhisper = if (inputState.isWhisperTabActive) { + dialogViewModel::showNewWhisper + } else null, onInputActionsChanged = mainScreenViewModel::updateInputActions, overflowExpanded = inputOverflowExpanded, onOverflowExpandedChanged = { inputOverflowExpanded = it }, @@ -611,48 +605,56 @@ fun MainScreen( // Shared toolbar action handler val handleToolbarAction: (ToolbarAction) -> Unit = { action -> when (action) { - is ToolbarAction.SelectTab -> { + is ToolbarAction.SelectTab -> { channelTabViewModel.selectTab(action.index) scope.launch { composePagerState.scrollToPage(action.index) } } + is ToolbarAction.LongClickTab -> { channelTabViewModel.selectTab(action.index) scope.launch { composePagerState.scrollToPage(action.index) } } - ToolbarAction.AddChannel -> { + + ToolbarAction.AddChannel -> { coordinator.onAddedChannelFromToolbar() dialogViewModel.showAddChannel() } - ToolbarAction.OpenMentions -> sheetNavigationViewModel.openMentions() - 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 -> { + + ToolbarAction.OpenMentions -> sheetNavigationViewModel.openMentions() + 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 -> { + + ToolbarAction.CaptureVideo -> { if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else dialogViewModel.setPendingUploadAction(onCaptureVideo) } - ToolbarAction.ChooseMedia -> { + + ToolbarAction.ChooseMedia -> { if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else dialogViewModel.setPendingUploadAction(onChooseMedia) } - ToolbarAction.ReloadEmotes -> { + + ToolbarAction.ReloadEmotes -> { activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } onReloadEmotes() } - ToolbarAction.Reconnect -> { + + ToolbarAction.Reconnect -> { channelManagementViewModel.reconnect() onReconnect() } - ToolbarAction.ClearChat -> dialogViewModel.showClearChat() - ToolbarAction.ToggleStream -> activeChannel?.let { streamViewModel.toggleStream(it) } - ToolbarAction.OpenSettings -> onNavigateToSettings() - ToolbarAction.MessageHistory -> activeChannel?.let { sheetNavigationViewModel.openHistory(it) } + + ToolbarAction.ClearChat -> dialogViewModel.showClearChat() + ToolbarAction.ToggleStream -> activeChannel?.let { streamViewModel.toggleStream(it) } + ToolbarAction.OpenSettings -> onNavigateToSettings() + ToolbarAction.MessageHistory -> activeChannel?.let { sheetNavigationViewModel.openHistory(it) } } } @@ -750,7 +752,8 @@ fun MainScreen( ChatComposable( channel = channel, onUserClick = { userId, userName, displayName, channel, badges, _ -> - dialogViewModel.showUserPopup(UserPopupStateParams( + dialogViewModel.showUserPopup( + UserPopupStateParams( targetUserId = userId?.let { UserId(it) } ?: UserId(""), targetUserName = UserName(userName), targetDisplayName = DisplayName(displayName), @@ -759,14 +762,16 @@ fun MainScreen( )) }, onMessageLongClick = { messageId, channel, fullMessage -> - dialogViewModel.showMessageOptions(MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - )) + dialogViewModel.showMessageOptions( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true + ) + ) }, onEmoteClick = { emotes -> dialogViewModel.showEmoteInfo(emotes) @@ -787,8 +792,8 @@ fun MainScreen( top = chatTopPadding + 56.dp, bottom = paddingValues.calculateBottomPadding() + inputHeightDp + when { !effectiveShowInput && !isFullscreen -> max(navBarHeightDp, roundedCornerBottomPadding) - !effectiveShowInput -> roundedCornerBottomPadding - else -> 0.dp + !effectiveShowInput -> roundedCornerBottomPadding + else -> 0.dp } ), scrollModifier = chatScrollModifier, @@ -844,7 +849,7 @@ fun MainScreen( val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> val effectiveBottomPadding = when { !effectiveShowInput -> bottomPadding + max(navBarHeightDp, roundedCornerBottomPadding) - else -> bottomPadding + else -> bottomPadding } FullScreenSheetOverlay( sheetState = fullScreenSheetState, @@ -886,9 +891,10 @@ fun MainScreen( ) { Row(modifier = Modifier.fillMaxSize()) { // Left pane: Stream - Box(modifier = Modifier - .weight(splitFraction) - .fillMaxSize() + Box( + modifier = Modifier + .weight(splitFraction) + .fillMaxSize() ) { StreamView( channel = currentStream, @@ -903,95 +909,96 @@ fun MainScreen( } // Right pane: Chat + all overlays - Box(modifier = Modifier - .weight(1f - splitFraction) - .fillMaxSize() - ) { - val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - - Scaffold( - modifier = modifier + Box( + modifier = Modifier + .weight(1f - splitFraction) .fillMaxSize() - .padding(bottom = scaffoldBottomPadding), - contentWindowInsets = WindowInsets(0), - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.padding(bottom = inputHeightDp), - ) - }, - ) { paddingValues -> - scaffoldContent(paddingValues, statusBarTop) - } + ) { + val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - val chatPaneWidthDp = with(density) { (containerWidthPx * (1f - splitFraction)).toInt().toDp() } - val showTabsInSplit = chatPaneWidthDp > 250.dp + Scaffold( + modifier = modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), + contentWindowInsets = WindowInsets(0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = inputHeightDp), + ) + }, + ) { paddingValues -> + scaffoldContent(paddingValues, statusBarTop) + } - floatingToolbar( - Modifier.align(Alignment.TopCenter), - !isKeyboardVisible && !inputState.isEmoteMenuOpen && !isSheetOpen, - false, - showTabsInSplit, - ) + val chatPaneWidthDp = with(density) { (containerWidthPx * (1f - splitFraction)).toInt().toDp() } + val showTabsInSplit = chatPaneWidthDp > 250.dp - // Status bar scrim when toolbar is gesture-hidden - if (!isFullscreen && mainState.gestureToolbarHidden) { - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + floatingToolbar( + Modifier.align(Alignment.TopCenter), + !isKeyboardVisible && !inputState.isEmoteMenuOpen && !isSheetOpen, + false, + showTabsInSplit, ) - } - fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + // Status bar scrim when toolbar is gesture-hidden + if (!isFullscreen && mainState.gestureToolbarHidden) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + ) + } + + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) - // Dismiss scrim for input overflow menu - if (inputOverflowExpanded) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - if (!tourController.forceOverflowOpen) { - inputOverflowExpanded = false + // Dismiss scrim for input overflow menu + if (inputOverflowExpanded) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (!tourController.forceOverflowOpen) { + inputOverflowExpanded = false + } } - } - ) - } - - // Input bar - rendered after sheet overlay so it's on top - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, ) - ) { - bottomBar() - } - - emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + } - if (effectiveShowInput && isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, + // Input bar - rendered after sheet overlay so it's on top + Box( modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp) - ) + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ) + ) { + bottomBar() + } + + emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + + if (effectiveShowInput && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp) + ) + } } } - } // Draggable handle overlaid at the split edge DraggableHandle( @@ -1177,14 +1184,16 @@ private class StreamToolbarState( if (hasVisibleStream) wasKeyboardClosingWithStream = false when { - keyboardClosingWithStream -> { + keyboardClosingWithStream -> { alpha.animateTo(0f, tween(durationMillis = 150)) } - hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { + + hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { prevHasVisibleStream = hasVisibleStream alpha.snapTo(0f) alpha.animateTo(1f, tween(durationMillis = 350)) } + !hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { prevHasVisibleStream = hasVisibleStream alpha.snapTo(0f) 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 834e3788f..2764b71f5 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 @@ -63,4 +63,4 @@ val Int.isEven get() = (this % 2 == 0) 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 From 29683106ea56334102291dec53e2d48330a2f437 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 14:20:19 +0100 Subject: [PATCH 060/349] refactor: Reformat, reorder imports, and remove unused imports --- .../com/flxrs/dankchat/DankChatApplication.kt | 4 +- .../com/flxrs/dankchat/DankChatViewModel.kt | 2 +- .../com/flxrs/dankchat/auth/AuthDataStore.kt | 4 +- .../dankchat/chat/compose/BackgroundColor.kt | 4 +- .../dankchat/chat/compose/ChatComposable.kt | 54 +- .../chat/compose/ChatComposeViewModel.kt | 4 +- .../dankchat/chat/compose/ChatMessageText.kt | 2 - .../flxrs/dankchat/chat/compose/ChatScreen.kt | 26 +- .../chat/compose/ChatScrollBehavior.kt | 11 +- .../chat/compose/EmoteAnimationCoordinator.kt | 14 +- .../dankchat/chat/compose/EmoteScaling.kt | 22 +- .../dankchat/chat/compose/Linkification.kt | 4 +- .../dankchat/chat/compose/StackedEmote.kt | 12 +- .../dankchat/chat/compose/TextResource.kt | 8 +- .../compose/TextWithMeasuredInlineContent.kt | 13 +- .../chat/compose/messages/AutomodMessage.kt | 6 +- .../chat/compose/messages/PrivMessage.kt | 10 +- .../compose/messages/WhisperAndRedemption.kt | 10 +- .../messages/common/MessageTextBuilders.kt | 3 +- .../compose/EmoteInfoComposeViewModel.kt | 4 +- .../compose/MessageHistoryComposeViewModel.kt | 6 +- .../dankchat/chat/mention/MentionViewModel.kt | 2 +- .../chat/mention/compose/MentionComposable.kt | 37 +- .../compose/MentionComposeViewModel.kt | 5 +- .../compose/MessageOptionsComposeViewModel.kt | 2 - .../chat/replies/compose/RepliesComposable.kt | 44 +- .../compose/RepliesComposeViewModel.kt | 5 +- .../dankchat/chat/search/ChatItemFilter.kt | 42 +- .../chat/search/ChatSearchFilterParser.kt | 20 +- .../chat/search/SearchFilterSuggestions.kt | 6 +- .../chat/suggestion/SuggestionProvider.kt | 6 +- .../chat/user/compose/UserPopupDialog.kt | 12 +- .../dankchat/data/api/auth/AuthApiClient.kt | 2 +- .../data/api/eventapi/EventSubClient.kt | 6 +- .../data/api/eventapi/EventSubManager.kt | 2 +- .../dto/EventSubSubscriptionResponseDto.kt | 5 +- .../eventapi/dto/EventSubSubscriptionType.kt | 2 + .../dto/messages/EventSubMessageDto.kt | 6 +- .../dto/messages/NotificationMessageDto.kt | 2 +- .../dto/messages/WelcomeMessageDto.kt | 2 +- .../notification/ChannelModerateDto.kt | 35 +- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 4 +- .../dankchat/data/api/helix/HelixApiClient.kt | 6 +- .../data/api/helix/dto/ShieldModeStatusDto.kt | 2 +- .../data/database/DankChatDatabase.kt | 20 +- .../com/flxrs/dankchat/data/irc/IrcMessage.kt | 8 +- .../data/notification/NotificationData.kt | 1 - .../data/notification/NotificationService.kt | 1 - .../data/repo/HighlightsRepository.kt | 6 +- .../dankchat/data/repo/RepliesRepository.kt | 2 +- .../data/repo/channel/ChannelRepository.kt | 7 +- .../dankchat/data/repo/chat/ChatRepository.kt | 20 +- .../dankchat/data/repo/command/Command.kt | 1 + .../data/repo/command/CommandRepository.kt | 2 +- .../dankchat/data/repo/data/DataRepository.kt | 6 +- .../data/repo/emote/EmoteRepository.kt | 14 +- .../flxrs/dankchat/data/repo/emote/Emotes.kt | 14 +- .../data/repo/stream/StreamDataRepository.kt | 2 +- .../dankchat/data/twitch/badge/BadgeType.kt | 8 +- .../data/twitch/chat/ChatConnection.kt | 2 +- .../twitch/command/TwitchCommandRepository.kt | 2 +- .../data/twitch/message/AutomodMessage.kt | 2 +- .../dankchat/data/twitch/message/Message.kt | 2 +- .../data/twitch/message/ModerationMessage.kt | 112 ++-- .../data/twitch/message/UserNoticeMessage.kt | 2 +- .../pubsub/dto/redemption/PointRedemption.kt | 2 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 2 +- .../dankchat/domain/ChannelDataCoordinator.kt | 28 +- .../dankchat/domain/ChannelDataLoader.kt | 7 +- .../dankchat/login/compose/LoginScreen.kt | 8 +- .../com/flxrs/dankchat/main/InputState.kt | 2 +- .../compose/ChannelManagementViewModel.kt | 15 +- .../main/compose/ChannelPagerViewModel.kt | 2 +- .../dankchat/main/compose/ChannelTabRow.kt | 3 - .../main/compose/ChannelTabViewModel.kt | 6 +- .../dankchat/main/compose/ChatBottomBar.kt | 4 +- .../dankchat/main/compose/ChatInputLayout.kt | 96 ++-- .../main/compose/ChatInputViewModel.kt | 24 +- .../main/compose/DialogStateViewModel.kt | 122 ++++- .../main/compose/EmoteMenuViewModel.kt | 3 +- .../dankchat/main/compose/FloatingToolbar.kt | 515 +++++++++--------- .../main/compose/FullScreenSheetOverlay.kt | 7 +- .../flxrs/dankchat/main/compose/MainAppBar.kt | 41 +- .../main/compose/MainScreenDialogs.kt | 4 +- .../main/compose/MainScreenEventHandler.kt | 24 +- .../main/compose/MainScreenViewModel.kt | 13 +- .../dankchat/main/compose/QuickActionsMenu.kt | 23 +- .../main/compose/SheetNavigationViewModel.kt | 15 +- .../flxrs/dankchat/main/compose/StreamView.kt | 2 +- .../dankchat/main/compose/StreamViewModel.kt | 3 +- .../main/compose/dialogs/AddChannelDialog.kt | 2 +- .../main/compose/dialogs/EditChannelDialog.kt | 4 +- .../main/compose/dialogs/EmoteInfoDialog.kt | 6 +- .../compose/dialogs/ManageChannelsDialog.kt | 8 +- .../compose/dialogs/MessageOptionsDialog.kt | 16 +- .../main/compose/dialogs/RoomStateDialog.kt | 1 + .../dankchat/main/compose/sheets/EmoteMenu.kt | 14 +- .../main/compose/sheets/EmoteMenuSheet.kt | 14 +- .../main/compose/sheets/MentionSheet.kt | 82 +-- .../compose/sheets/MessageHistorySheet.kt | 6 +- .../main/compose/sheets/RepliesSheet.kt | 54 +- .../onboarding/OnboardingDataStore.kt | 2 +- .../dankchat/onboarding/OnboardingScreen.kt | 10 +- .../preferences/DankChatPreferenceStore.kt | 2 +- .../appearance/AppearanceSettingsScreen.kt | 9 +- .../appearance/AppearanceSettingsViewModel.kt | 16 +- .../preferences/chat/ChatSettingsDataStore.kt | 33 +- .../preferences/chat/ChatSettingsViewModel.kt | 2 +- .../developer/DeveloperSettingsScreen.kt | 1 - .../NotificationsSettingsDataStore.kt | 6 +- .../notifications/highlights/HighlightItem.kt | 1 - .../highlights/HighlightsScreen.kt | 26 +- .../highlights/HighlightsViewModel.kt | 8 +- .../notifications/ignores/IgnoresScreen.kt | 2 +- .../dankchat/share/ShareUploadActivity.kt | 6 +- .../dankchat/tour/FeatureTourController.kt | 25 +- .../tour/PostOnboardingCoordinator.kt | 10 +- .../com/flxrs/dankchat/utils/MediaUtils.kt | 3 +- .../utils/compose/RoundedCornerPadding.kt | 2 +- .../dankchat/utils/datastore/Migration.kt | 1 + 120 files changed, 1097 insertions(+), 958 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 851f420e3..2cda437f9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -9,7 +9,6 @@ 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 @@ -30,12 +29,11 @@ import kotlinx.coroutines.launch 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.* +import org.koin.ksp.generated.module class DankChatApplication : Application(), SingletonImageLoader.Factory { // Dummy comment to force KSP re-run - private val dispatchersProvider: DispatchersProvider by inject() private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchersProvider.main) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 824dd0b3b..69ee758bc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -7,9 +7,9 @@ import com.flxrs.dankchat.auth.AuthStateCoordinator import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.repo.data.DataRepository import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn diff --git a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt index 5295ae1f9..1b0c86b1b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt @@ -36,8 +36,8 @@ class AuthDataStore( private val sharedPrefsMigration = object : DataMigration { override suspend fun shouldMigrate(currentData: AuthSettings): Boolean { return legacyPrefs.contains(LEGACY_LOGGED_IN_KEY) || - legacyPrefs.contains(LEGACY_OAUTH_KEY) || - legacyPrefs.contains(LEGACY_NAME_KEY) + legacyPrefs.contains(LEGACY_OAUTH_KEY) || + legacyPrefs.contains(LEGACY_NAME_KEY) } override suspend fun migrate(currentData: AuthSettings): AuthSettings { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt index 2c36aa5e0..3783ae17c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt @@ -20,8 +20,8 @@ fun rememberBackgroundColor(lightColor: Color, darkColor: Color): Color { return remember(raw, background) { when { raw == Color.Transparent -> Color.Transparent - raw.alpha < 1f -> raw.compositeOver(background) - else -> raw + raw.alpha < 1f -> raw.compositeOver(background) + else -> raw } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index 52e27a852..d509d24fd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -64,32 +64,32 @@ fun ChatComposable( val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { - ChatScreen( - messages = messages, - fontSize = displaySettings.fontSize, - showLineSeparator = displaySettings.showLineSeparator, - animateGifs = displaySettings.animateGifs, - modifier = modifier.fillMaxSize(), - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick, - showInput = showInput, - isFullscreen = isFullscreen, - hasHelperText = hasHelperText, - showFabs = showFabs, - onRecover = onRecover, - contentPadding = contentPadding, - scrollModifier = scrollModifier, - onScrollToBottom = onScrollToBottom, - onScrollDirectionChanged = onScrollDirectionChanged, - scrollToMessageId = scrollToMessageId, - onScrollToMessageHandled = onScrollToMessageHandled, - onAutomodAllow = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = true) }, - onAutomodDeny = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = false) }, - recoveryFabTooltipState = recoveryFabTooltipState, - onTourAdvance = onTourAdvance, - onTourSkip = onTourSkip, - ) + ChatScreen( + messages = messages, + fontSize = displaySettings.fontSize, + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, + modifier = modifier.fillMaxSize(), + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick, + showInput = showInput, + isFullscreen = isFullscreen, + hasHelperText = hasHelperText, + showFabs = showFabs, + onRecover = onRecover, + contentPadding = contentPadding, + scrollModifier = scrollModifier, + onScrollToBottom = onScrollToBottom, + onScrollDirectionChanged = onScrollDirectionChanged, + scrollToMessageId = scrollToMessageId, + onScrollToMessageHandled = onScrollToMessageHandled, + onAutomodAllow = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = true) }, + onAutomodDeny = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = false) }, + recoveryFabTooltipState = recoveryFabTooltipState, + onTourAdvance = onTourAdvance, + onTourSkip = onTourSkip, + ) } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index f1fd4fdec..3f1aec290 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.chat.compose import android.util.Log +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.auth.AuthDataStore @@ -17,19 +18,16 @@ import com.flxrs.dankchat.preferences.chat.ChatSettings import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils import com.flxrs.dankchat.utils.extensions.isEven -import androidx.compose.runtime.Immutable import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam import java.time.Instant -import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt index f381b865a..e27ae232b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.chat.compose import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.material3.MaterialTheme @@ -16,7 +15,6 @@ 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.TextUnit -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 2579384c6..50dac41ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -30,6 +31,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface +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 @@ -39,8 +44,8 @@ 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.graphics.Color import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -54,11 +59,6 @@ import com.flxrs.dankchat.chat.compose.messages.PrivMessageComposable import com.flxrs.dankchat.chat.compose.messages.SystemMessageComposable import com.flxrs.dankchat.chat.compose.messages.UserNoticeMessageComposable import com.flxrs.dankchat.chat.compose.messages.WhisperMessageComposable -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.TooltipAnchorPosition -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.TooltipState import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.main.compose.TourTooltip @@ -164,7 +164,9 @@ fun ChatScreen( state = listState, reverseLayout = true, contentPadding = contentPadding, - modifier = Modifier.fillMaxSize().then(scrollModifier) + modifier = Modifier + .fillMaxSize() + .then(scrollModifier) ) { items( items = reversedMessages, @@ -179,7 +181,7 @@ fun ChatScreen( is ChatMessageUiState.PrivMessageUi -> "privmsg" is ChatMessageUiState.WhisperMessageUi -> "whisper" is ChatMessageUiState.PointRedemptionMessageUi -> "redemption" - is ChatMessageUiState.DateSeparatorUi -> "datesep" + is ChatMessageUiState.DateSeparatorUi -> "datesep" } } ) { message -> @@ -211,9 +213,9 @@ fun ChatScreen( val bottomContentPadding = contentPadding.calculateBottomPadding() val fabBottomPadding by animateDpAsState( targetValue = when { - showInput -> bottomContentPadding - hasHelperText -> maxOf(bottomContentPadding, 48.dp) - else -> maxOf(bottomContentPadding, 24.dp) + showInput -> bottomContentPadding + hasHelperText -> maxOf(bottomContentPadding, 48.dp) + else -> maxOf(bottomContentPadding, 24.dp) }, animationSpec = if (showInput) snap() else spring(), label = "fabBottomPadding" @@ -405,7 +407,7 @@ private fun ChatMessageItem( fontSize = fontSize ) - is ChatMessageUiState.DateSeparatorUi -> DateSeparatorComposable( + is ChatMessageUiState.DateSeparatorUi -> DateSeparatorComposable( message = message, fontSize = fontSize ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt index f268cea9c..abfc11fde 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt @@ -37,8 +37,13 @@ class ScrollDirectionTracker( } accumulated += available.y when { - accumulated > hideThresholdPx -> { onHide(); accumulated = 0f } - accumulated < -showThresholdPx -> { onShow(); accumulated = 0f } + accumulated > hideThresholdPx -> { + onHide(); accumulated = 0f + } + + accumulated < -showThresholdPx -> { + onShow(); accumulated = 0f + } } return Offset.Zero } @@ -82,7 +87,7 @@ fun Modifier.swipeDownToHide( if (!enabled) return this return this.pointerInput(enabled) { awaitEachGesture { - val down = awaitFirstDown(pass = PointerEventPass.Initial, requireUnconsumed = false) + awaitFirstDown(pass = PointerEventPass.Initial, requireUnconsumed = false) var totalDragY = 0f var fired = false while (true) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt index 91c52849e..54409a7cf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt @@ -43,7 +43,7 @@ class EmoteAnimationCoordinator( // Cache of known emote dimensions (width, height in px) to avoid layout shifts val dimensionCache = LruCache>(512) - + /** * Get or load an emote drawable. * @@ -59,13 +59,13 @@ class EmoteAnimationCoordinator( } return cached } - + // Load the emote via Coil (Coil handles concurrent requests internally) return try { val request = ImageRequest.Builder(platformContext) .data(url) .build() - + val result = imageLoader.execute(request) if (result is SuccessResult) { val image = result.image @@ -88,24 +88,24 @@ class EmoteAnimationCoordinator( null } } - + /** * Check if an emote is already cached. */ fun getCached(url: String): Drawable? = emoteCache.get(url) - + /** * Put a drawable in the cache (used by AsyncImage onSuccess callback). */ fun putInCache(url: String, drawable: Drawable) { emoteCache.put(url, drawable) } - + /** * Get a cached LayerDrawable for stacked emotes. */ fun getLayerCached(cacheKey: String): LayerDrawable? = layerCache.get(cacheKey) - + /** * Put a LayerDrawable in the cache for stacked emotes. */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt index fa3fd2db3..bf0094af7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt @@ -19,7 +19,7 @@ import kotlin.math.roundToInt object EmoteScaling { private const val BASE_HEIGHT_CONSTANT = 1.173 private const val SCALE_FACTOR_CONSTANT = 1.5 / 112 - + /** * Calculate base emote height from font size. * This matches the line height of text. @@ -27,7 +27,7 @@ object EmoteScaling { fun getBaseHeight(fontSizeSp: Float): Dp { return (fontSizeSp * BASE_HEIGHT_CONSTANT).dp } - + /** * Calculate scale factor exactly as ChatAdapter did from fontSize in SP. */ @@ -35,14 +35,14 @@ object EmoteScaling { val baseHeight = fontSizeSp * BASE_HEIGHT_CONSTANT return baseHeight * SCALE_FACTOR_CONSTANT } - + /** * Calculate scale factor from base height in pixels. */ fun getScaleFactor(baseHeightPx: Int): Double { return baseHeightPx * SCALE_FACTOR_CONSTANT } - + /** * Calculate scaled emote dimensions matching old ChatAdapter.transformEmoteDrawable() EXACTLY. * @@ -68,24 +68,24 @@ object EmoteScaling { baseHeightPx: Int ): Pair { val scale = baseHeightPx * SCALE_FACTOR_CONSTANT - + val ratio = intrinsicWidth / intrinsicHeight.toFloat() - + // Match ChatAdapter height calculation exactly val height = when { - intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() + intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() intrinsicHeight in 55..111 && emote.isTwitch -> (112 * scale).roundToInt() - else -> (intrinsicHeight * scale).roundToInt() + else -> (intrinsicHeight * scale).roundToInt() } val width = (height * ratio).roundToInt() - + // Apply individual emote scale val scaledWidth = (width.toFloat() * emote.scale).roundToInt() val scaledHeight = (height.toFloat() * emote.scale).roundToInt() - + return Pair(scaledWidth, scaledHeight) } - + /** * Calculate badge dimensions. * Badges are always square at the base height. diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt index edc2b4310..f484cedc3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt @@ -1,11 +1,11 @@ package com.flxrs.dankchat.chat.compose +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 -import android.util.Patterns private val DISALLOWED_URL_CHARS = """<>\{}|^"`""".toSet() @@ -35,7 +35,7 @@ fun AnnotatedString.Builder.appendWithLinks(text: String, linkColor: Color, prev fixedEnd++ } end = fixedEnd - + val url = text.substring(start, end) // Append text before URL diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt index b4ed32e5f..c61a848e1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt @@ -10,9 +10,9 @@ 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.runtime.remember import coil3.asDrawable import coil3.compose.LocalPlatformContext import coil3.imageLoader @@ -47,7 +47,7 @@ fun StackedEmote( val baseHeight = EmoteScaling.getBaseHeight(fontSize) val baseHeightPx = with(density) { baseHeight.toPx().toInt() } val scaleFactor = EmoteScaling.getScaleFactor(baseHeightPx) - + // For single emote, render directly without LayerDrawable if (emote.urls.size == 1 && emote.emotes.isNotEmpty()) { SingleEmoteDrawable( @@ -62,10 +62,10 @@ fun StackedEmote( ) 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)) @@ -242,9 +242,9 @@ private fun transformEmoteDrawable( ): Drawable { val ratio = drawable.intrinsicWidth / drawable.intrinsicHeight.toFloat() val height = when { - drawable.intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() + drawable.intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() drawable.intrinsicHeight in 55..111 && emote.isTwitch -> (112 * scale).roundToInt() - else -> (drawable.intrinsicHeight * scale).roundToInt() + else -> (drawable.intrinsicHeight * scale).roundToInt() } val width = (height * ratio).roundToInt() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt index 7d281f997..31b5cf24f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt @@ -23,12 +23,12 @@ sealed interface TextResource { @Composable fun TextResource.resolve(): String = when (this) { - is TextResource.Plain -> value - is TextResource.Res -> { + is TextResource.Plain -> value + is TextResource.Res -> { val resolvedArgs = args.map { arg -> when (arg) { is TextResource -> arg.resolve() - else -> arg + else -> arg } } stringResource(id, *resolvedArgs.toTypedArray()) @@ -38,7 +38,7 @@ fun TextResource.resolve(): String = when (this) { val resolvedArgs = args.map { arg -> when (arg) { is TextResource -> arg.resolve() - else -> arg + else -> arg } } pluralStringResource(id, quantity, *resolvedArgs.toTypedArray()) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt index 1a3736658..c3b1c08d7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity @@ -69,7 +68,7 @@ fun TextWithMeasuredInlineContent( val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() val textLayoutResultRef = remember { mutableStateOf(null) } - + SubcomposeLayout(modifier = modifier) { constraints -> // Phase 1: Measure inline content to get actual dimensions // Skip measurement for IDs with pre-known dimensions (from cache) @@ -112,9 +111,9 @@ fun TextWithMeasuredInlineContent( inlineContentProviders[id]?.invoke() } } - + // Phase 3: Compose the text with correct inline content - + val textMeasurables = subcompose("text") { BasicText( text = text, @@ -154,14 +153,14 @@ fun TextWithMeasuredInlineContent( } ) } - + if (textMeasurables.isEmpty()) { return@SubcomposeLayout layout(0, 0) {} } - + // Phase 4: Measure and layout the text val textPlaceable = textMeasurables.first().measure(constraints) - + layout(textPlaceable.width, textPlaceable.height) { textPlaceable.place(0, 0) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt index 3ea049f56..b48c02d96 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt @@ -101,7 +101,7 @@ fun AutomodMessageComposable( // Allow / Deny buttons or status text when (message.status) { - AutomodMessageStatus.Pending -> { + AutomodMessageStatus.Pending -> { pushStringAnnotation(tag = ALLOW_TAG, annotation = message.heldMessageId) withStyle(SpanStyle(color = allowColor, fontWeight = FontWeight.Bold)) { append(allowText) @@ -121,13 +121,13 @@ fun AutomodMessageComposable( } } - AutomodMessageStatus.Denied -> { + AutomodMessageStatus.Denied -> { withStyle(SpanStyle(color = denyColor.copy(alpha = 0.6f), fontWeight = FontWeight.Bold)) { append(deniedText) } } - AutomodMessageStatus.Expired -> { + AutomodMessageStatus.Expired -> { withStyle(SpanStyle(color = textColor.copy(alpha = 0.5f), fontWeight = FontWeight.Bold)) { append(expiredText) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index c474e4e27..339e1674c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -23,12 +23,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString 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.style.TextOverflow @@ -44,12 +44,12 @@ import com.flxrs.dankchat.chat.compose.EmoteDimensions import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote -import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberNormalizedColor +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @@ -332,7 +332,7 @@ private fun PrivMessageText( onUserClick(userId, userName, displayName, channel, message.badges, false) } } - + // Handle URL clicks annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> @@ -350,4 +350,4 @@ private fun PrivMessageText( onMessageLongClick(message.id, message.channel.value, message.fullMessage) } ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 7f30238ec..22dd72d53 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -23,9 +23,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -38,7 +38,6 @@ import androidx.compose.ui.unit.sp import androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext -import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.EmoteDimensions @@ -50,6 +49,7 @@ import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberNormalizedColor +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote /** @@ -290,7 +290,7 @@ private fun WhisperMessageText( onUserClick(userId, userName, displayName, message.badges, false) } } - + // Handle URL clicks annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> @@ -321,7 +321,7 @@ fun PointRedemptionMessageComposable( ) { val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val timestampColor = rememberAdaptiveTextColor(backgroundColor) - + Box( modifier = modifier .fillMaxWidth() @@ -391,4 +391,4 @@ fun PointRedemptionMessageComposable( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt index 189a5e189..5da7d1d9f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color 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.withStyle @@ -147,7 +146,7 @@ fun buildInlineContentProviders( onEmoteClick: (List) -> Unit ): Map Unit> { val badgeSize = EmoteScaling.getBadgeSize(fontSize) - + return buildMap { // Badge providers badges.forEach { badge -> diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt index a484168d8..242f8a932 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt @@ -58,7 +58,7 @@ class EmoteInfoComposeViewModel( is ChatMessageEmoteType.GlobalFFZEmote -> "$FFZ_BASE_LINK$id-$code" is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" - is ChatMessageEmoteType.Cheermote -> "$TWITCH_BASE_LINK$id" + is ChatMessageEmoteType.Cheermote -> "$TWITCH_BASE_LINK$id" } } @@ -71,7 +71,7 @@ class EmoteInfoComposeViewModel( 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 + ChatMessageEmoteType.Cheermote -> R.string.emote_sheet_twitch_emote } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt index d7611eec8..a3f951280 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt @@ -22,6 +22,7 @@ import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -35,7 +36,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take -import com.flxrs.dankchat.utils.extensions.isEven import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @@ -131,12 +131,12 @@ class MessageHistoryComposeViewModel( val lastSpaceIndex = currentText.trimEnd().lastIndexOf(' ') val prefix = when { lastSpaceIndex >= 0 -> currentText.substring(0, lastSpaceIndex + 1) - else -> "" + else -> "" } val keyword = suggestion.toString() val suffix = when { keyword.endsWith(':') -> "" - else -> " " + else -> " " } val newText = prefix + keyword + suffix searchFieldState.edit { 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 index 8f7fc6d75..790ee27bf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt @@ -13,7 +13,7 @@ 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index b78a01775..e911bb049 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -5,17 +5,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.LocalPlatformContext import coil3.imageLoader -import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import androidx.compose.ui.graphics.Color -import com.flxrs.dankchat.chat.compose.ChatDisplaySettings /** * Standalone composable for mentions/whispers display. @@ -49,20 +48,20 @@ fun MentionComposable( val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { - ChatScreen( - messages = messages, - fontSize = displaySettings.fontSize, - showLineSeparator = displaySettings.showLineSeparator, - animateGifs = displaySettings.animateGifs, - showChannelPrefix = !isWhisperTab, - modifier = modifier, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onWhisperReply = if (isWhisperTab) onWhisperReply else null, - onJumpToMessage = if (!isWhisperTab) onJumpToMessage else null, - contentPadding = contentPadding, - containerColor = containerColor, - ) + ChatScreen( + messages = messages, + fontSize = displaySettings.fontSize, + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, + showChannelPrefix = !isWhisperTab, + modifier = modifier, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onWhisperReply = if (isWhisperTab) onWhisperReply else null, + onJumpToMessage = if (!isWhisperTab) onJumpToMessage else null, + contentPadding = contentPadding, + containerColor = containerColor, + ) } // CompositionLocalProvider -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt index 4a6744635..43acac72e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.chat.mention.compose import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.compose.runtime.Immutable import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.chat.compose.ChatDisplaySettings import com.flxrs.dankchat.chat.compose.ChatMessageMapper @@ -11,18 +10,16 @@ import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn -import com.flxrs.dankchat.utils.extensions.isEven import org.koin.android.annotation.KoinViewModel -import kotlin.time.Duration.Companion.seconds @KoinViewModel class MentionComposeViewModel( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt index afc72b6e1..bb3c18520 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt @@ -12,7 +12,6 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam -import kotlin.time.Duration.Companion.seconds @KoinViewModel class MessageOptionsComposeViewModel( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index bd417ea82..7d0783806 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.CompositionLocalProvider 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 coil3.compose.LocalPlatformContext import coil3.imageLoader @@ -14,8 +15,6 @@ import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.replies.RepliesUiState -import androidx.compose.ui.graphics.Color -import com.flxrs.dankchat.chat.compose.ChatDisplaySettings /** * Standalone composable for reply thread display. @@ -44,26 +43,27 @@ fun RepliesComposable( val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { - when (uiState) { - is RepliesUiState.Found -> { - ChatScreen( - messages = (uiState as RepliesUiState.Found).items, - fontSize = displaySettings.fontSize, - showLineSeparator = displaySettings.showLineSeparator, - animateGifs = displaySettings.animateGifs, - modifier = modifier, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = { /* no-op for replies */ }, - contentPadding = contentPadding, - containerColor = containerColor, - ) - } - is RepliesUiState.NotFound -> { - LaunchedEffect(Unit) { - onNotFound() + when (uiState) { + is RepliesUiState.Found -> { + ChatScreen( + messages = (uiState as RepliesUiState.Found).items, + fontSize = displaySettings.fontSize, + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, + modifier = modifier, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = { /* no-op for replies */ }, + contentPadding = contentPadding, + containerColor = containerColor, + ) + } + + is RepliesUiState.NotFound -> { + LaunchedEffect(Unit) { + onNotFound() + } } } - } } // CompositionLocalProvider -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt index 31615c21e..7a4d35a71 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt @@ -10,6 +10,7 @@ import com.flxrs.dankchat.data.repo.RepliesRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -18,9 +19,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel -import com.flxrs.dankchat.utils.extensions.isEven import org.koin.core.annotation.InjectedParam -import kotlin.time.Duration.Companion.seconds @KoinViewModel class RepliesComposeViewModel( @@ -59,7 +58,7 @@ class RepliesComposeViewModel( ) { repliesState, appearanceSettings, chatSettings -> when (repliesState) { is RepliesState.NotFound -> RepliesUiState.NotFound - is RepliesState.Found -> { + is RepliesState.Found -> { val uiMessages = repliesState.items.mapIndexed { index, item -> val altBg = index.isEven && appearanceSettings.checkeredMessages chatMessageMapper.mapToUiState( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt index 15d736c59..740154a8f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt @@ -12,10 +12,10 @@ object ChatItemFilter { 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.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 @@ -23,54 +23,52 @@ object ChatItemFilter { } private fun matchText(item: ChatItem, query: String): Boolean { - val message = item.message - return when (message) { - is PrivMessage -> message.message.contains(query, ignoreCase = true) + return when (val message = item.message) { + is PrivMessage -> message.message.contains(query, ignoreCase = true) is UserNoticeMessage -> message.message.contains(query, ignoreCase = true) - else -> false + else -> false } } private fun matchAuthor(item: ChatItem, name: String): Boolean { - val message = item.message - return when (message) { + return when (val message = item.message) { is PrivMessage -> { message.name.value.equals(name, ignoreCase = true) || - message.displayName.value.equals(name, ignoreCase = true) + message.displayName.value.equals(name, ignoreCase = true) } - else -> false + + else -> false } } private fun matchLink(item: ChatItem): Boolean { - val message = item.message - return when (message) { + return when (val message = item.message) { is PrivMessage -> URL_REGEX.containsMatchIn(message.message) - else -> false + else -> false } } private fun matchEmote(item: ChatItem, emoteName: String?): Boolean { - val message = item.message - return when (message) { + return 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 + + else -> false } } private fun matchBadge(item: ChatItem, badgeName: String): Boolean { - val message = item.message - return when (message) { + return 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 + badge.title?.contains(badgeName, ignoreCase = true) == true } - else -> false + + else -> false } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt index 5dcd24c28..8e6ad804b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt @@ -25,22 +25,24 @@ object ChatSearchFilterParser { val value = raw.substring(colonIndex + 1) when (prefix) { - "from" -> return when { + "from" -> return when { isBeingTyped || value.isEmpty() -> null - else -> ChatSearchFilter.Author(name = value, negate = negate) + else -> ChatSearchFilter.Author(name = value, negate = negate) } - "has" -> return when (value.lowercase()) { - "link" -> ChatSearchFilter.HasLink(negate = negate) + + "has" -> return when (value.lowercase()) { + "link" -> ChatSearchFilter.HasLink(negate = negate) "emote" -> ChatSearchFilter.HasEmote(emoteName = null, negate = negate) - "" -> null - else -> when { + "" -> null + else -> when { isBeingTyped -> null - else -> ChatSearchFilter.HasEmote(emoteName = value, negate = negate) + else -> ChatSearchFilter.HasEmote(emoteName = value, negate = negate) } } + "badge" -> return when { isBeingTyped || value.isEmpty() -> null - else -> ChatSearchFilter.BadgeFilter(badgeName = value.lowercase(), negate = negate) + else -> ChatSearchFilter.BadgeFilter(badgeName = value.lowercase(), negate = negate) } } } @@ -51,7 +53,7 @@ object ChatSearchFilterParser { private fun extractNegation(token: String): Pair { return when { token.startsWith('!') || token.startsWith('-') -> true to token.substring(1) - else -> false to token + else -> false to token } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt index 660fce8de..278f34370 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt @@ -41,12 +41,12 @@ object SearchFilterSuggestions { val partial = lastToken.substring(colonIndex + 1) return when (prefix.lowercase()) { - "from:" -> users + "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 -> + "has:" -> HAS_VALUES.filter { suggestion -> val value = suggestion.displayText.orEmpty() value.startsWith(partial, ignoreCase = true) && !value.equals(partial, ignoreCase = true) } @@ -56,7 +56,7 @@ object SearchFilterSuggestions { .take(MAX_VALUE_SUGGESTIONS) .map { Suggestion.FilterSuggestion(keyword = "badge:$it", descriptionRes = R.string.search_filter_badge, displayText = it) } - else -> emptyList() + else -> emptyList() } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt index 2f48462ec..fa0f2a38e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt @@ -55,9 +55,9 @@ class SuggestionProvider( // Order suggestions based on user preference val orderedSuggestions = when { preferEmotes -> emotes + users + commands - else -> users + emotes + commands + else -> users + emotes + commands } - + // Limit results to reasonable number orderedSuggestions.take(MAX_SUGGESTIONS) } @@ -128,7 +128,7 @@ class SuggestionProvider( return suggestions.mapNotNull { suggestion -> when { constraint.startsWith('@') -> suggestion.copy(withLeadingAt = true) - else -> suggestion + else -> suggestion }.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index 5e1693a2a..310861ff0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -4,6 +4,7 @@ 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.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -11,7 +12,6 @@ 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.layout.FlowRow import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat @@ -27,13 +27,13 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TooltipBox 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 @@ -89,7 +89,7 @@ fun UserPopupDialog( ) } - else -> { + else -> { val userName = state.userName val displayName = state.displayName val isSuccess = state is UserPopupState.Success @@ -219,7 +219,7 @@ private fun UserInfoSection( } } - is UserPopupState.Error -> {} + is UserPopupState.Error -> {} } Spacer(modifier = Modifier.width(16.dp)) @@ -282,7 +282,7 @@ private fun UserInfoSection( } } - else -> {} // Loading — name is shown, details load later + else -> {} // Loading — name is shown, details load later } } } 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 35e9931ed..2d15c923f 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 @@ -1,9 +1,9 @@ package com.flxrs.dankchat.data.api.auth +import com.flxrs.dankchat.auth.AuthSettings 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.auth.AuthSettings import com.flxrs.dankchat.utils.extensions.decodeOrNull import io.ktor.client.call.body import io.ktor.client.statement.bodyAsText 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 4644cdc0d..25befb054 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 @@ -261,8 +261,7 @@ class EventSubClient( private fun handleNotification(message: NotificationMessageDto) { Log.d(TAG, "[EventSub] received notification message: $message") - val event = message.payload.event - val eventSubMessage = when (event) { + val eventSubMessage = when (val event = message.payload.event) { is ChannelModerateDto -> ModerationAction( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, @@ -296,8 +295,7 @@ class EventSubClient( private fun DefaultClientWebSocketSession.handleReconnect(message: ReconnectMessageDto) { Log.i(TAG, "[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) { + when (val url = message.payload.session.reconnectUrl?.replaceFirst("ws://", "wss://")) { null -> reconnect() else -> { previousSession = this 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 cc160c276..1a9e8719a 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,10 +1,10 @@ package com.flxrs.dankchat.data.api.eventapi +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob 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 4e6c69cee..a5dc37961 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,8 +7,10 @@ import kotlinx.serialization.Serializable enum class EventSubSubscriptionType { @SerialName("channel.moderate") ChannelModerate, + @SerialName("automod.message.hold") AutomodMessageHold, + @SerialName("automod.message.update") AutomodMessageUpdate, 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..f21a16d21 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,9 +3,9 @@ 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") 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/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/helix/HelixApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApi.kt index f836cac96..9d5764ecc 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 @@ -1,17 +1,17 @@ package com.flxrs.dankchat.data.api.helix +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionRequestDto 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.ManageAutomodMessageRequestDto 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.ShieldModeRequestDto import com.flxrs.dankchat.data.api.helix.dto.WhisperRequestDto -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix import io.ktor.client.HttpClient import io.ktor.client.request.bearerAuth 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 ca7e20d59..4a3a7acfa 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,14 +7,15 @@ 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.CheermoteSetDto -import com.flxrs.dankchat.data.api.helix.dto.ManageAutomodMessageRequestDto +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 @@ -23,7 +24,6 @@ import com.flxrs.dankchat.data.api.helix.dto.RaidDto 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.ChannelEmoteDto 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 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/database/DankChatDatabase.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/DankChatDatabase.kt index 37d37b663..3aa32727e 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, 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 974019b9f..6310ddc5d 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 @@ -25,10 +25,10 @@ data class IrcMessage( 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(';') + 's' -> append(' ') + 'r' -> append('\r') + 'n' -> append('\n') '\\' -> append('\\') else -> { append(value[i]) 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..81fec5510 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 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 27fe79a07..f7b933eb3 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 @@ -7,7 +7,6 @@ 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 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 8917be161..5a4c6f9b6 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 @@ -98,7 +98,7 @@ class HighlightsRepository( if (badgeHighlightDao.getBadgeHighlights().isEmpty()) { Log.d(TAG, "Running badge highlights migration...") badgeHighlightDao.addHighlights(DEFAULT_BADGE_HIGHLIGHTS) - val totalBadgeHighlights = + DEFAULT_BADGE_HIGHLIGHTS.size + val totalBadgeHighlights = +DEFAULT_BADGE_HIGHLIGHTS.size Log.d(TAG, "Badge highlights migration completed, added $totalBadgeHighlights entries.") } }.getOrElse { @@ -326,13 +326,13 @@ class HighlightsRepository( private suspend fun WhisperMessage.calculateHighlightState(): WhisperMessage = when { notificationsSettingsDataStore.settings.first().showWhisperNotifications -> copy(highlights = setOf(Highlight(HighlightType.Notification))) - else -> this + else -> this } private val List.subsHighlight: MessageHighlightEntity? get() = find { it.type == MessageHighlightEntityType.Subscription } - private val List.announcementsHighlight : MessageHighlightEntity? + private val List.announcementsHighlight: MessageHighlightEntity? get() = find { it.type == MessageHighlightEntityType.Announcement } private val List.rewardsHighlight: MessageHighlightEntity? 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 eae7998d3..069caf007 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,5 +1,6 @@ package com.flxrs.dankchat.data.repo +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.toDisplayName @@ -9,7 +10,6 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.utils.extensions.replaceIf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow 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 840c39d09..aa518868e 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 @@ -1,5 +1,6 @@ package com.flxrs.dankchat.data.repo.channel +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.HelixApiClient @@ -9,7 +10,6 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.utils.extensions.firstValue import com.flxrs.dankchat.utils.extensions.firstValueOrNull import kotlinx.coroutines.Dispatchers @@ -42,7 +42,7 @@ class ChannelRepository( .getOrNull() ?.let { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } - else -> null + else -> null } ?: tryGetChannelFromIrc(name) if (channel != null) { @@ -139,7 +139,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/ChatRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatRepository.kt index 3e9de7af9..a60aa9017 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 @@ -2,8 +2,11 @@ package com.flxrs.dankchat.data.repo.chat import android.graphics.Color import android.util.Log +import com.flxrs.dankchat.R +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.chat.ChatImportance import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.chat.compose.TextResource import com.flxrs.dankchat.chat.toMentionTabItems import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName @@ -29,14 +32,11 @@ 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.badge.Badge +import com.flxrs.dankchat.data.twitch.badge.BadgeType 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.badge.Badge -import com.flxrs.dankchat.data.twitch.badge.BadgeType -import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.TextResource -import kotlinx.collections.immutable.persistentListOf import com.flxrs.dankchat.data.twitch.message.AutomodMessage import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.ModerationMessage @@ -53,7 +53,6 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.extensions.INVISIBLE_CHAR @@ -69,6 +68,7 @@ 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.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -343,7 +343,7 @@ class ChatRepository( suspend fun loadRecentMessagesIfEnabled(channel: UserName) { when { chatSettingsDataStore.settings.first().loadMessageHistory -> loadRecentMessages(channel) - else -> messages[channel]?.update { current -> + else -> messages[channel]?.update { current -> val message = SystemMessageType.NoHistoryLoaded.toChatItem() listOf(message).addAndLimit(current, scrollBackLength, ::onMessageRemoved, checkForDuplications = true) } @@ -723,7 +723,7 @@ class ChatRepository( }.orEmpty() } - else -> emptyList() + else -> emptyList() } val message = runCatching { @@ -982,7 +982,7 @@ class ChatRepository( blockedTerm: BlockedTermReasonDto?, messageText: String, ): TextResource = when { - reason == "automod" && automod != null -> TextResource.Res(R.string.automod_reason_category, persistentListOf(automod.category, automod.level)) + 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 @@ -993,7 +993,7 @@ class ChatRepository( TextResource.PluralRes(R.plurals.automod_reason_blocked_terms, count, persistentListOf(count, terms)) } - else -> TextResource.Plain(reason) + else -> TextResource.Plain(reason) } fun updateAutomodMessageStatus(channel: UserName, heldMessageId: String, status: AutomodMessage.Status) { 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..8c5ddd751 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 @@ -3,6 +3,7 @@ package com.flxrs.dankchat.data.repo.command enum class Command(val trigger: String) { Block(trigger = "/block"), Unblock(trigger = "/unblock"), + //Chatters(trigger = "/chatters"), Uptime(trigger = "/uptime"), 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 f6594f291..23af28015 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,6 +1,7 @@ package com.flxrs.dankchat.data.repo.command import android.util.Log +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.HelixApiClient import com.flxrs.dankchat.data.api.supibot.SupibotApiClient @@ -13,7 +14,6 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.CustomCommand import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore 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 fbe500ab9..e37d5e800 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,7 @@ package com.flxrs.dankchat.data.repo.data import android.util.Log +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName @@ -21,7 +22,6 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.VisibleThirdPartyEmotes import com.flxrs.dankchat.utils.extensions.measureTimeAndLog @@ -130,7 +130,7 @@ class DataRepository( suspend fun loadGlobalBadges(): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "global badges") { val result = when { - authDataStore.isLoggedIn -> helixApiClient.getGlobalBadges().map { it.toBadgeSets() } + 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 } @@ -162,7 +162,7 @@ class DataRepository( suspend fun loadChannelBadges(channel: UserName, id: UserId): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "channel badges for #$id") { val result = when { - authDataStore.isLoggedIn -> helixApiClient.getChannelBadges(id).map { it.toBadgeSets() } + 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) } 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 0e3cfdeab..4d9f2130c 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,8 +1,8 @@ package com.flxrs.dankchat.data.repo.emote import android.graphics.drawable.Drawable -import android.util.Log import android.graphics.drawable.LayerDrawable +import android.util.Log import android.util.LruCache import androidx.annotation.VisibleForTesting import com.flxrs.dankchat.data.DisplayName @@ -14,13 +14,13 @@ 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.helix.HelixApiClient -import com.flxrs.dankchat.data.api.helix.dto.CheermoteSetDto -import com.flxrs.dankchat.data.api.helix.HelixApiException -import com.flxrs.dankchat.data.api.helix.dto.UserEmoteDto 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.HelixApiException +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 @@ -30,9 +30,7 @@ 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.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.BadgeSet import com.flxrs.dankchat.data.twitch.badge.BadgeType @@ -853,7 +851,7 @@ class EmoteRepository( 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 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 7fe9e019f..d5af72ca5 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 @@ -24,13 +24,13 @@ fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes { 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, -) + ffzChannelEmotes = channel.ffzEmotes, + ffzGlobalEmotes = global.ffzEmotes, + bttvChannelEmotes = channel.bttvEmotes, + bttvGlobalEmotes = global.bttvEmotes, + sevenTvChannelEmotes = channel.sevenTvEmotes, + sevenTvGlobalEmotes = global.sevenTvEmotes, + ) } data class Emotes( 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 index bba796045..5172ed1ba 100644 --- 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 @@ -1,10 +1,10 @@ package com.flxrs.dankchat.data.repo.stream +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.data.DataRepository import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.main.StreamData -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils 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..e2d1e2665 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 @@ -12,11 +12,11 @@ enum class BadgeType { 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 3b2335600..28e3204f3 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 @@ -2,10 +2,10 @@ package com.flxrs.dankchat.data.twitch.chat import android.util.Log import com.flxrs.dankchat.BuildConfig +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.irc.IrcMessage import com.flxrs.dankchat.data.toUserName -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.utils.extensions.timer import io.ktor.http.HttpHeaders 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 529b2bda7..fa622f123 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,7 @@ package com.flxrs.dankchat.data.twitch.command import android.util.Log +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.api.helix.HelixApiClient @@ -19,7 +20,6 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.utils.DateTimeUtils import org.koin.core.annotation.Single import java.util.UUID 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 index 437c0e123..b70cde964 100644 --- 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 @@ -16,7 +16,7 @@ data class AutomodMessage( val messageText: String, val reason: TextResource, val badges: List = emptyList(), - val color: Int = Message.DEFAULT_COLOR, + val color: Int = DEFAULT_COLOR, val status: Status = Status.Pending, ) : Message() { 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..a37633ead 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 @@ -40,7 +40,7 @@ sealed class Message { if (pairs.isEmpty()) return@mapNotNull null // skip over invalid parsed data - val parsedPositions = pairs.mapNotNull positions@ { pos -> + val parsedPositions = pairs.mapNotNull positions@{ pos -> val pair = pos.split('-') if (pair.size != 2) return@positions null 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 91e6313f6..107957d11 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 @@ -2,7 +2,6 @@ package com.flxrs.dankchat.data.twitch.message import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.TextResource -import kotlinx.collections.immutable.persistentListOf import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelModerateAction @@ -13,11 +12,12 @@ 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 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(), @@ -109,7 +109,7 @@ data class ModerationMessage( fun getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): TextResource { return when (action) { - Action.Timeout -> when (targetUser) { + Action.Timeout -> when (targetUser) { currentUser -> TextResource.Res(R.string.mod_timeout_self, persistentListOf(durationSuffix, creatorSuffix, quotedReasonSuffix, countSuffix())) else -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_timeout_no_creator, persistentListOf(targetUserDisplay.toString(), durationSuffix, countSuffix())) @@ -117,8 +117,8 @@ data class ModerationMessage( } } - Action.Untimeout -> TextResource.Res(R.string.mod_untimeout, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Ban -> when (targetUser) { + Action.Untimeout -> TextResource.Res(R.string.mod_untimeout, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Ban -> when (targetUser) { currentUser -> TextResource.Res(R.string.mod_ban_self, persistentListOf(creatorSuffix, quotedReasonSuffix)) else -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_ban_no_creator, persistentListOf(targetUserDisplay.toString())) @@ -126,48 +126,69 @@ data class ModerationMessage( } } - Action.Unban -> TextResource.Res(R.string.mod_unban, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Mod -> TextResource.Res(R.string.mod_modded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unmod -> TextResource.Res(R.string.mod_unmodded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Delete -> when (creatorUserDisplay) { + Action.Unban -> TextResource.Res(R.string.mod_unban, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Mod -> TextResource.Res(R.string.mod_modded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unmod -> TextResource.Res(R.string.mod_unmodded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Delete -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_delete_no_creator, persistentListOf(targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) else -> TextResource.Res(R.string.mod_delete_by_creator, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) } - Action.Clear -> when (creatorUserDisplay) { + Action.Clear -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_clear_no_creator) else -> TextResource.Res(R.string.mod_clear_by_creator, persistentListOf(creatorUserDisplay.toString())) } - Action.Vip -> TextResource.Res(R.string.mod_vip_added, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unvip -> TextResource.Res(R.string.mod_vip_removed, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Warn -> { + Action.Vip -> TextResource.Res(R.string.mod_vip_added, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unvip -> TextResource.Res(R.string.mod_vip_removed, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Warn -> { val suffix = when (val r = reasonsSuffix) { is TextResource.Plain -> TextResource.Plain(".") else -> r } TextResource.Res(R.string.mod_warn, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), suffix)) } - Action.Raid -> TextResource.Res(R.string.mod_raid, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unraid -> TextResource.Res(R.string.mod_unraid, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.EmoteOnly -> TextResource.Res(R.string.mod_emote_only_on, persistentListOf(creatorUserDisplay.toString())) - Action.EmoteOnlyOff -> TextResource.Res(R.string.mod_emote_only_off, persistentListOf(creatorUserDisplay.toString())) - Action.Followers -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creatorUserDisplay.toString(), minutesSuffix())) - Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, persistentListOf(creatorUserDisplay.toString())) - Action.UniqueChat -> TextResource.Res(R.string.mod_unique_chat_on, persistentListOf(creatorUserDisplay.toString())) - Action.UniqueChatOff -> TextResource.Res(R.string.mod_unique_chat_off, persistentListOf(creatorUserDisplay.toString())) - Action.Slow -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creatorUserDisplay.toString(), secondsSuffix())) - Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, persistentListOf(creatorUserDisplay.toString())) - Action.Subscribers -> TextResource.Res(R.string.mod_subscribers_on, persistentListOf(creatorUserDisplay.toString())) - Action.SubscribersOff -> TextResource.Res(R.string.mod_subscribers_off, persistentListOf(creatorUserDisplay.toString())) - Action.SharedTimeout -> TextResource.Res(R.string.mod_shared_timeout, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, sourceBroadcasterDisplay.toString(), countSuffix())) - Action.SharedUntimeout -> TextResource.Res(R.string.mod_shared_untimeout, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) - Action.SharedBan -> TextResource.Res(R.string.mod_shared_ban, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), quotedReasonSuffix)) - Action.SharedUnban -> TextResource.Res(R.string.mod_shared_unban, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString())) - Action.SharedDelete -> TextResource.Res(R.string.mod_shared_delete, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), sayingSuffix(showDeletedMessage))) - Action.AddBlockedTerm -> TextResource.Res(R.string.automod_moderation_added_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.AddPermittedTerm -> TextResource.Res(R.string.automod_moderation_added_permitted_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.RemoveBlockedTerm -> TextResource.Res(R.string.automod_moderation_removed_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + + Action.Raid -> TextResource.Res(R.string.mod_raid, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.Unraid -> TextResource.Res(R.string.mod_unraid, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) + Action.EmoteOnly -> TextResource.Res(R.string.mod_emote_only_on, persistentListOf(creatorUserDisplay.toString())) + Action.EmoteOnlyOff -> TextResource.Res(R.string.mod_emote_only_off, persistentListOf(creatorUserDisplay.toString())) + Action.Followers -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creatorUserDisplay.toString(), minutesSuffix())) + Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, persistentListOf(creatorUserDisplay.toString())) + Action.UniqueChat -> TextResource.Res(R.string.mod_unique_chat_on, persistentListOf(creatorUserDisplay.toString())) + Action.UniqueChatOff -> TextResource.Res(R.string.mod_unique_chat_off, persistentListOf(creatorUserDisplay.toString())) + Action.Slow -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creatorUserDisplay.toString(), secondsSuffix())) + Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, persistentListOf(creatorUserDisplay.toString())) + Action.Subscribers -> TextResource.Res(R.string.mod_subscribers_on, persistentListOf(creatorUserDisplay.toString())) + Action.SubscribersOff -> TextResource.Res(R.string.mod_subscribers_off, persistentListOf(creatorUserDisplay.toString())) + Action.SharedTimeout -> TextResource.Res( + R.string.mod_shared_timeout, + persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, sourceBroadcasterDisplay.toString(), countSuffix()) + ) + + Action.SharedUntimeout -> TextResource.Res( + R.string.mod_shared_untimeout, + persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString()) + ) + + Action.SharedBan -> TextResource.Res( + R.string.mod_shared_ban, + persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), quotedReasonSuffix) + ) + + Action.SharedUnban -> TextResource.Res( + R.string.mod_shared_unban, + persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString()) + ) + + Action.SharedDelete -> TextResource.Res( + R.string.mod_shared_delete, + persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), sayingSuffix(showDeletedMessage)) + ) + + Action.AddBlockedTerm -> TextResource.Res(R.string.automod_moderation_added_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.AddPermittedTerm -> TextResource.Res(R.string.automod_moderation_added_permitted_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + Action.RemoveBlockedTerm -> TextResource.Res(R.string.automod_moderation_removed_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) Action.RemovePermittedTerm -> TextResource.Res(R.string.automod_moderation_removed_permitted_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) } } @@ -297,17 +318,18 @@ data class ModerationMessage( } 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.SharedChatTimeout -> data.sharedChatTimeout?.reason + 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() } ChannelModerateAction.AddBlockedTerm, ChannelModerateAction.AddPermittedTerm, ChannelModerateAction.RemoveBlockedTerm, ChannelModerateAction.RemovePermittedTerm -> data.automodTerms?.terms?.joinToString(" and ") { "\"$it\"" } + else -> null } @@ -387,12 +409,12 @@ data class ModerationMessage( 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") + 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/UserNoticeMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserNoticeMessage.kt index 9b4e03523..a8de6f1af 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 @@ -33,7 +33,7 @@ data class UserNoticeMessage( 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"] 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/di/NetworkModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt index 77f661640..9a0253234 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.di import android.util.Log import com.flxrs.dankchat.BuildConfig +import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.api.auth.AuthApi import com.flxrs.dankchat.data.api.badges.BadgesApi import com.flxrs.dankchat.data.api.bttv.BTTVApi @@ -11,7 +12,6 @@ import com.flxrs.dankchat.data.api.helix.HelixApi 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.auth.AuthDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 241a589fd..09a6119d5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -1,31 +1,31 @@ package com.flxrs.dankchat.domain import android.util.Log -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.chat.ChatRepository -import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.data.state.ChannelLoadingState -import com.flxrs.dankchat.data.state.GlobalLoadingState -import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.data.UserName 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.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.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.CompletableDeferred import kotlinx.coroutines.launch -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap @@ -110,7 +110,7 @@ class ChannelDataCoordinator( _globalLoadingState.value = GlobalLoadingState.Loading dataRepository.clearDataLoadingFailures() - val results = globalDataLoader.loadGlobalData() + globalDataLoader.loadGlobalData() // Reparse after global emotes load so 3rd party globals are visible immediately chatRepository.reparseAllEmotesAndBadges() @@ -184,10 +184,10 @@ class ChannelDataCoordinator( fun retryDataLoading(dataFailures: Set, chatFailures: Set) { scope.launch { _globalLoadingState.value = GlobalLoadingState.Loading - + // Collect channels that need retry val channelsToRetry = mutableSetOf() - + val dataResults = dataFailures.map { failure -> async { when (val step = failure.step) { @@ -221,4 +221,4 @@ class ChannelDataCoordinator( companion object { private val TAG = ChannelDataCoordinator::class.java.simpleName } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index 77ee57070..b930882ad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -1,7 +1,7 @@ package com.flxrs.dankchat.domain -import com.flxrs.dankchat.data.UserName 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 @@ -12,7 +12,6 @@ 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.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext @@ -70,7 +69,7 @@ class ChannelDataLoader( is ChannelLoadingFailure.SevenTVEmotes -> SystemMessageType.ChannelSevenTVEmotesFailed(status) is ChannelLoadingFailure.BTTVEmotes -> SystemMessageType.ChannelBTTVEmotesFailed(status) is ChannelLoadingFailure.FFZEmotes -> SystemMessageType.ChannelFFZEmotesFailed(status) - else -> null + else -> null } systemMessageType?.let { chatRepository.makeAndPostSystemMessage(it, channel) @@ -79,7 +78,7 @@ class ChannelDataLoader( when { failures.isEmpty() -> emit(ChannelLoadingState.Loaded) - else -> emit(ChannelLoadingState.Failed("Some data failed to load", failures)) + else -> emit(ChannelLoadingState.Failed("Some data failed to load", failures)) } } catch (e: Exception) { emit(ChannelLoadingState.Failed(e.message ?: "Unknown error", emptyList())) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt index c0ef08c1f..a88fdc4b8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt @@ -80,9 +80,11 @@ fun LoginScreen( ) } ) { paddingValues -> - Column(modifier = Modifier - .padding(paddingValues) - .fillMaxSize()) { + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { if (isLoading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt index ccad80c94..4eda14931 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt @@ -5,5 +5,5 @@ sealed interface InputState { object Replying : InputState object Whispering : InputState object NotLoggedIn : InputState - object Disconnected: InputState + object Disconnected : InputState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index 5ecf287d5..366f7d226 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -3,6 +3,8 @@ package com.flxrs.dankchat.main.compose 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.ChatRepository import com.flxrs.dankchat.domain.ChannelDataCoordinator import com.flxrs.dankchat.preferences.DankChatPreferenceStore @@ -13,9 +15,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel -import com.flxrs.dankchat.data.repo.IgnoresRepository -import com.flxrs.dankchat.data.repo.channel.ChannelRepository - @KoinViewModel class ChannelManagementViewModel( private val preferenceStore: DankChatPreferenceStore, @@ -25,7 +24,7 @@ class ChannelManagementViewModel( private val channelRepository: ChannelRepository, ) : ViewModel() { - val channels: StateFlow> = + val channels: StateFlow> = preferenceStore.getChannelsWithRenamesFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -39,14 +38,14 @@ class ChannelManagementViewModel( } } } - + // 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) @@ -127,12 +126,12 @@ class ChannelManagementViewModel( // 1. Cleanup removed channels if (removedChannels.isNotEmpty()) { chatRepository.updateChannels(newChannelNames) // This handles join/part - removedChannels.forEach { channel -> + removedChannels.forEach { channel -> channelDataCoordinator.cleanupChannel(channel) // Remove rename preferenceStore.setRenamedChannel(ChannelWithRename(channel, null)) } - + // 2. Update active channel if removed val activeChannel = chatRepository.activeChannel.value if (activeChannel in removedChannels) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt index 3db3d7234..5870db8f5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -1,8 +1,8 @@ package com.flxrs.dankchat.main.compose +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt index 6f372f6fc..437c7163d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt @@ -1,13 +1,10 @@ package com.flxrs.dankchat.main.compose import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt index 3875dede4..ae8eef270 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt @@ -1,8 +1,8 @@ package com.flxrs.dankchat.main.compose +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.state.ChannelLoadingState @@ -61,7 +61,7 @@ class ChannelTabViewModel( .indexOfFirst { it.channel == active } .coerceAtLeast(0), loading = globalState == GlobalLoadingState.Loading - || tabs.any { it.loadingState == ChannelLoadingState.Loading }, + || tabs.any { it.loadingState == ChannelLoadingState.Loading }, ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) @@ -92,4 +92,4 @@ data class ChannelTabItem( val hasUnread: Boolean, val mentionCount: Int, val loadingState: ChannelLoadingState -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index d1e5e69bc..d4ed435d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -64,7 +64,7 @@ fun ChatBottomBar( enter = EnterTransition.None, exit = when { instantHide -> ExitTransition.None - else -> slideOutVertically(targetOffsetY = { it }) + else -> slideOutVertically(targetOffsetY = { it }) }, ) { ChatInputLayout( @@ -115,7 +115,7 @@ fun ChatBottomBar( if (!helperText.isNullOrEmpty()) { val horizontalPadding = when { isFullscreen -> rememberRoundedCornerHorizontalPadding(fallback = 16.dp) - else -> PaddingValues(horizontal = 16.dp) + else -> PaddingValues(horizontal = 16.dp) } Surface( color = MaterialTheme.colorScheme.surfaceContainer, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 89fa1a14f..6ecca75bb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -1,7 +1,6 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility -import androidx.compose.runtime.Immutable import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandVertically @@ -9,9 +8,8 @@ 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.foundation.clickable import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -20,6 +18,7 @@ import androidx.compose.foundation.layout.IntrinsicSize 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -34,45 +33,41 @@ import androidx.compose.material.icons.automirrored.filled.Send 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.Edit import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Keyboard -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Settings +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.VisibilityOff import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.RichTooltip -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.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.foundation.layout.height 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.LaunchedEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -89,10 +84,8 @@ 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.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalDensity import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.InputState @@ -115,7 +108,6 @@ data class TourOverlayState( val onSkip: (() -> Unit)? = null, ) - @Composable fun ChatInputLayout( textFieldState: TextFieldState, @@ -157,10 +149,10 @@ fun ChatInputLayout( ) { val focusRequester = remember { FocusRequester() } val hint = when (inputState) { - InputState.Default -> stringResource(R.string.hint_connected) - InputState.Replying -> stringResource(R.string.hint_replying) - InputState.Whispering -> stringResource(R.string.hint_whispering) - InputState.NotLoggedIn -> stringResource(R.string.hint_not_logged_int) + InputState.Default -> stringResource(R.string.hint_connected) + InputState.Replying -> stringResource(R.string.hint_replying) + InputState.Whispering -> stringResource(R.string.hint_whispering) + InputState.NotLoggedIn -> stringResource(R.string.hint_not_logged_int) InputState.Disconnected -> stringResource(R.string.hint_disconnected) } @@ -184,9 +176,9 @@ fun ChatInputLayout( val effectiveActions = remember(inputActions, isModerator, hasStreamData, isStreamActive) { inputActions.filter { action -> when (action) { - InputAction.Stream -> hasStreamData || isStreamActive + InputAction.Stream -> hasStreamData || isStreamActive InputAction.RoomState -> isModerator - else -> true + else -> true } }.toImmutableList() } @@ -301,13 +293,13 @@ fun ChatInputLayout( modifier = Modifier.height(IntrinsicSize.Min), ) { when (characterCounter) { - is CharacterCounterState.Hidden -> Unit + is CharacterCounterState.Hidden -> Unit is CharacterCounterState.Visible -> { Text( text = characterCounter.text, color = when { characterCounter.isOverLimit -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurfaceVariant }, style = MaterialTheme.typography.labelSmall, ) @@ -514,9 +506,9 @@ fun ChatInputLayout( } else { endAlignedContent() } + } } } - } actionsRowContent() } @@ -569,12 +561,12 @@ fun ChatInputLayout( tourState = tourState, onActionClick = { action -> when (action) { - InputAction.Search -> onSearchClick() + InputAction.Search -> onSearchClick() InputAction.LastMessage -> onLastMessageClick() - InputAction.Stream -> onToggleStream() - InputAction.RoomState -> onChangeRoomState() - InputAction.Fullscreen -> onToggleFullscreen() - InputAction.HideInput -> onToggleInput() + InputAction.Stream -> onToggleStream() + InputAction.RoomState -> onChangeRoomState() + InputAction.Fullscreen -> onToggleFullscreen() + InputAction.HideInput -> onToggleInput() } onOverflowExpandedChanged(false) }, @@ -732,22 +724,22 @@ private fun InputActionConfigSheet( private val InputAction.labelRes: Int get() = when (this) { - InputAction.Search -> R.string.input_action_search + InputAction.Search -> R.string.input_action_search InputAction.LastMessage -> R.string.input_action_last_message - InputAction.Stream -> R.string.input_action_stream - InputAction.RoomState -> R.string.input_action_room_state - InputAction.Fullscreen -> R.string.input_action_fullscreen - InputAction.HideInput -> R.string.input_action_hide_input + InputAction.Stream -> R.string.input_action_stream + InputAction.RoomState -> R.string.input_action_room_state + InputAction.Fullscreen -> R.string.input_action_fullscreen + InputAction.HideInput -> R.string.input_action_hide_input } private val InputAction.icon: ImageVector get() = when (this) { - InputAction.Search -> Icons.Default.Search + InputAction.Search -> Icons.Default.Search InputAction.LastMessage -> Icons.Default.History - InputAction.Stream -> Icons.Default.Videocam - InputAction.RoomState -> Icons.Default.Shield - InputAction.Fullscreen -> Icons.Default.Fullscreen - InputAction.HideInput -> Icons.Default.VisibilityOff + InputAction.Stream -> Icons.Default.Videocam + InputAction.RoomState -> Icons.Default.Shield + InputAction.Fullscreen -> Icons.Default.Fullscreen + InputAction.HideInput -> Icons.Default.VisibilityOff } @Composable @@ -791,26 +783,28 @@ private fun InputActionButton( modifier: Modifier = Modifier, ) { val (icon, contentDescription, onClick) = when (action) { - InputAction.Search -> Triple(Icons.Default.Search, R.string.message_history, onSearchClick) + InputAction.Search -> Triple(Icons.Default.Search, R.string.message_history, onSearchClick) InputAction.LastMessage -> Triple(Icons.Default.History, R.string.resume_scroll, onLastMessageClick) - InputAction.Stream -> Triple( + InputAction.Stream -> Triple( if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, R.string.toggle_stream, onToggleStream, ) - InputAction.RoomState -> Triple(Icons.Default.Shield, R.string.menu_room_state, onChangeRoomState) - InputAction.Fullscreen -> Triple( + + InputAction.RoomState -> Triple(Icons.Default.Shield, R.string.menu_room_state, onChangeRoomState) + InputAction.Fullscreen -> Triple( if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, R.string.toggle_fullscreen, onToggleFullscreen, ) - InputAction.HideInput -> Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) + + InputAction.HideInput -> Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) } val actionEnabled = when (action) { InputAction.Search, InputAction.Fullscreen, InputAction.HideInput -> true - InputAction.LastMessage -> enabled && hasLastMessage - InputAction.Stream, InputAction.RoomState -> enabled + InputAction.LastMessage -> enabled && hasLastMessage + InputAction.Stream, InputAction.RoomState -> enabled } IconButton( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index b22b44296..03dcca372 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.placeCursorAtEnd import androidx.compose.runtime.Immutable import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.compose.ui.text.TextRange import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.chat.suggestion.SuggestionProvider import com.flxrs.dankchat.data.DisplayName @@ -16,15 +16,14 @@ 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.repo.emote.EmoteUsageRepository import com.flxrs.dankchat.data.repo.stream.StreamDataRepository -import com.flxrs.dankchat.data.repo.command.CommandResult import com.flxrs.dankchat.data.twitch.chat.ConnectionState import com.flxrs.dankchat.data.twitch.command.TwitchCommand import com.flxrs.dankchat.main.InputState import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.main.RepeatedSendData -import com.flxrs.dankchat.main.compose.FullScreenSheetState import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore @@ -35,7 +34,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -257,19 +255,20 @@ class ChatInputViewModel( val canTypeInConnectionState = connectionState == ConnectionState.CONNECTED || !autoDisableInput val inputState = when (connectionState) { - ConnectionState.CONNECTED -> when { + ConnectionState.CONNECTED -> when { isWhisperTabActive && whisperTarget != null -> InputState.Whispering - effectiveIsReplying -> InputState.Replying - else -> InputState.Default + effectiveIsReplying -> InputState.Replying + else -> InputState.Default } + ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn - ConnectionState.DISCONNECTED -> InputState.Disconnected + ConnectionState.DISCONNECTED -> InputState.Disconnected } val enabled = when { isMentionsTabActive -> false - isWhisperTabActive -> isLoggedIn && canTypeInConnectionState && whisperTarget != null - else -> isLoggedIn && canTypeInConnectionState + isWhisperTabActive -> isLoggedIn && canTypeInConnectionState && whisperTarget != null + else -> isLoggedIn && canTypeInConnectionState } val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn && enabled @@ -325,8 +324,8 @@ class ChatInputViewModel( val chatState = fullScreenSheetState.value val replyIdOrNull = when { chatState is FullScreenSheetState.Replies -> chatState.replyMessageId - _isReplying.value -> _replyMessageId.value - else -> null + _isReplying.value -> _replyMessageId.value + else -> null } val commandResult = runCatching { @@ -532,6 +531,7 @@ data class ChatInputUiState( sealed interface CharacterCounterState { data object Hidden : CharacterCounterState + @Immutable data class Visible(val text: String, val isOverLimit: Boolean) : CharacterCounterState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt index 8c972f288..11328316d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt @@ -23,54 +23,122 @@ class DialogStateViewModel( val state: StateFlow = _state.asStateFlow() // Channel dialogs - fun showAddChannel() { update { copy(showAddChannel = true) } } - fun dismissAddChannel() { update { copy(showAddChannel = false) } } + fun showAddChannel() { + update { copy(showAddChannel = true) } + } - fun showManageChannels() { update { copy(showManageChannels = true) } } - fun dismissManageChannels() { update { copy(showManageChannels = false) } } + fun dismissAddChannel() { + update { copy(showAddChannel = false) } + } - fun showRemoveChannel() { update { copy(showRemoveChannel = true) } } - fun dismissRemoveChannel() { update { copy(showRemoveChannel = false) } } + fun showManageChannels() { + update { copy(showManageChannels = true) } + } - fun showBlockChannel() { update { copy(showBlockChannel = true) } } - fun dismissBlockChannel() { update { copy(showBlockChannel = false) } } + fun dismissManageChannels() { + update { copy(showManageChannels = false) } + } - fun showClearChat() { update { copy(showClearChat = true) } } - fun dismissClearChat() { update { copy(showClearChat = false) } } + fun showRemoveChannel() { + update { copy(showRemoveChannel = true) } + } - fun showRoomState() { update { copy(showRoomState = true) } } - fun dismissRoomState() { update { copy(showRoomState = false) } } + fun dismissRemoveChannel() { + update { copy(showRemoveChannel = false) } + } + + fun showBlockChannel() { + update { copy(showBlockChannel = true) } + } + + fun dismissBlockChannel() { + update { copy(showBlockChannel = false) } + } + + fun showClearChat() { + update { copy(showClearChat = true) } + } + + fun dismissClearChat() { + update { copy(showClearChat = false) } + } + + fun showRoomState() { + update { copy(showRoomState = true) } + } + + fun dismissRoomState() { + update { copy(showRoomState = false) } + } // Auth dialogs - fun showLogout() { update { copy(showLogout = true) } } - fun dismissLogout() { update { copy(showLogout = false) } } + fun showLogout() { + update { copy(showLogout = true) } + } - fun showLoginOutdated(username: UserName) { update { copy(loginOutdated = username) } } - fun dismissLoginOutdated() { update { copy(loginOutdated = null) } } + fun dismissLogout() { + update { copy(showLogout = false) } + } - fun showLoginExpired() { update { copy(showLoginExpired = true) } } - fun dismissLoginExpired() { update { copy(showLoginExpired = false) } } + fun showLoginOutdated(username: UserName) { + update { copy(loginOutdated = username) } + } + + fun dismissLoginOutdated() { + update { copy(loginOutdated = null) } + } + + fun showLoginExpired() { + update { copy(showLoginExpired = true) } + } + + fun dismissLoginExpired() { + update { copy(showLoginExpired = false) } + } // Whisper dialog - fun showNewWhisper() { update { copy(showNewWhisper = true) } } - fun dismissNewWhisper() { update { copy(showNewWhisper = false) } } + fun showNewWhisper() { + update { copy(showNewWhisper = true) } + } + + fun dismissNewWhisper() { + update { copy(showNewWhisper = false) } + } // Upload - fun setPendingUploadAction(action: (() -> Unit)?) { update { copy(pendingUploadAction = action) } } - fun setUploading(uploading: Boolean) { update { copy(isUploading = uploading) } } + fun setPendingUploadAction(action: (() -> Unit)?) { + update { copy(pendingUploadAction = action) } + } + + 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 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) } } + fun showEmoteInfo(emotes: List) { + update { copy(emoteInfoEmotes = emotes.toImmutableList()) } + } + + fun dismissEmoteInfo() { + update { copy(emoteInfoEmotes = null) } + } private inline fun update(crossinline transform: DialogState.() -> DialogState) { _state.value = _state.value.transform() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt index 4a298409f..beb19e181 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt @@ -17,14 +17,13 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.distinctUntilChanged 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.withContext import org.koin.android.annotation.KoinViewModel -import kotlin.time.Duration.Companion.seconds @KoinViewModel class EmoteMenuViewModel( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index 944daf24b..f3fea21c5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -20,7 +20,6 @@ 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.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -30,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -55,16 +55,14 @@ 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.mutableStateOf -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -72,18 +70,18 @@ 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.foundation.background import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +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.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.R -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onSizeChanged +import com.flxrs.dankchat.data.UserName import kotlinx.coroutines.delay import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first @@ -176,7 +174,6 @@ fun FloatingToolbar( } } - // Dismiss scrim for inline overflow menu if (showOverflowMenu) { Box( @@ -229,293 +226,293 @@ fun FloatingToolbar( } Box(modifier = scrimModifier) { - // Auto-scroll whenever the selected tab isn't fully visible - LaunchedEffect(Unit) { - snapshotFlow { - val currentIndex = composePagerState.currentPage - val layoutInfo = tabListState.layoutInfo - val viewportStart = layoutInfo.viewportStartOffset - val viewportEnd = layoutInfo.viewportEndOffset - val itemInfo = layoutInfo.visibleItemsInfo.find { it.index == currentIndex } - when { - itemInfo == null -> currentIndex - itemInfo.offset < viewportStart -> currentIndex - itemInfo.offset + itemInfo.size > viewportEnd -> currentIndex - else -> -1 - } - }.collect { targetIndex -> - if (targetIndex >= 0) { - tabListState.animateScrollToItem(targetIndex) + // Auto-scroll whenever the selected tab isn't fully visible + LaunchedEffect(Unit) { + snapshotFlow { + val currentIndex = composePagerState.currentPage + val layoutInfo = tabListState.layoutInfo + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + val itemInfo = layoutInfo.visibleItemsInfo.find { it.index == currentIndex } + when { + itemInfo == null -> currentIndex + itemInfo.offset < viewportStart -> currentIndex + itemInfo.offset + itemInfo.size > viewportEnd -> currentIndex + else -> -1 + } + }.collect { targetIndex -> + if (targetIndex >= 0) { + tabListState.animateScrollToItem(targetIndex) + } } } - } - // Mention indicators based on visibility - val hasLeftMention by remember { - derivedStateOf { - val visibleItems = tabListState.layoutInfo.visibleItemsInfo - val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 - tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } - } - } - val hasRightMention by remember { - derivedStateOf { - val visibleItems = tabListState.layoutInfo.visibleItemsInfo - val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) - tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } + // Mention indicators based on visibility + val hasLeftMention by remember { + derivedStateOf { + val visibleItems = tabListState.layoutInfo.visibleItemsInfo + val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 + tabState.tabs.take(firstVisibleIndex).any { it.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)) + val hasRightMention by remember { + derivedStateOf { + val visibleItems = tabListState.layoutInfo.visibleItemsInfo + val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) + tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } + } } - // 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(), - ) { - Box(modifier = if (endAligned) Modifier.fillMaxWidth() else Modifier) { - val mentionGradientColor = MaterialTheme.colorScheme.error - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + Row( 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) - ) - } - }, + .fillMaxWidth() + .padding(horizontal = 8.dp) + .onSizeChanged { + val h = it.height.toFloat() + if (toolbarRowHeight == 0f || h < toolbarRowHeight) toolbarRowHeight = h + }, + verticalAlignment = Alignment.Top ) { - LazyRow( - state = tabListState, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .wrapLazyRowContent(tabListState, extraWidth = with(density) { 24.dp.roundToPx() }) - .padding(horizontal = 12.dp) - .clipToBounds() + // 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(), ) { - itemsIndexed( - items = tabState.tabs, - key = { _, tab -> tab.channel.value } - ) { index, tab -> - val isSelected = index == selectedIndex - val textColor = when { - isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - Row( - verticalAlignment = Alignment.CenterVertically, + Box(modifier = if (endAligned) Modifier.fillMaxWidth() else Modifier) { + val mentionGradientColor = MaterialTheme.colorScheme.error + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier - .combinedClickable( - onClick = { onAction(ToolbarAction.SelectTab(index)) }, - onLongClick = { - onAction(ToolbarAction.LongClickTab(index)) - overflowInitialMenu = AppBarMenu.Channel - showOverflowMenu = true + .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) + ) } - ) - .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 12.dp) + 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) + ) + } + }, ) { - Text( - text = tab.displayName, - color = textColor, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - if (tab.mentionCount > 0) { - Spacer(Modifier.width(4.dp)) - Badge() + LazyRow( + state = tabListState, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .wrapLazyRowContent(tabListState, extraWidth = with(density) { 24.dp.roundToPx() }) + .padding(horizontal = 12.dp) + .clipToBounds() + ) { + itemsIndexed( + items = tabState.tabs, + key = { _, tab -> tab.channel.value } + ) { index, tab -> + val isSelected = index == selectedIndex + val textColor = when { + isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .combinedClickable( + onClick = { onAction(ToolbarAction.SelectTab(index)) }, + onLongClick = { + onAction(ToolbarAction.LongClickTab(index)) + overflowInitialMenu = AppBarMenu.Channel + showOverflowMenu = true + } + ) + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 12.dp) + ) { + Text( + text = tab.displayName, + color = textColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(4.dp)) + Badge() + } + } + } } } } } - } - } - } - // Action icons + inline overflow menu (animated with expand/collapse) - AnimatedVisibility( - visible = !isTabsExpanded, - enter = expandHorizontally( - expandFrom = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeIn(tween(200)), - exit = shrinkHorizontally( - shrinkTowards = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeOut(tween(150)) - ) { - Row(verticalAlignment = Alignment.Top) { - Spacer(Modifier.width(8.dp)) + // Action icons + inline overflow menu (animated with expand/collapse) + AnimatedVisibility( + visible = !isTabsExpanded, + enter = expandHorizontally( + expandFrom = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeIn(tween(200)), + exit = shrinkHorizontally( + shrinkTowards = Alignment.Start, + animationSpec = tween(350, easing = FastOutSlowInEasing) + ) + fadeOut(tween(150)) + ) { + Row(verticalAlignment = Alignment.Top) { + Spacer(Modifier.width(8.dp)) - val pillCornerRadius by animateDpAsState( - targetValue = if (showOverflowMenu) 0.dp else 28.dp, - animationSpec = tween(200), - label = "pillCorner" - ) - Column(modifier = Modifier.width(IntrinsicSize.Min)) { - Surface( - shape = RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - bottomStart = pillCornerRadius, - bottomEnd = pillCornerRadius - ), - 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() + val pillCornerRadius by animateDpAsState( + targetValue = if (showOverflowMenu) 0.dp else 28.dp, + animationSpec = tween(200), + label = "pillCorner" + ) + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Surface( + shape = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = pillCornerRadius, + bottomEnd = pillCornerRadius + ), + 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)) } - LaunchedEffect(Unit) { - snapshotFlow { addChannelTooltipState.isVisible } - .dropWhile { !it } // skip initial false - .first { !it } // wait for dismiss (any cause) - onAddChannelTooltipDismissed() + val addChannelIcon: @Composable () -> Unit = { + IconButton(onClick = { onAction(ToolbarAction.AddChannel) }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_channel) + ) + } } - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Above, - spacingBetweenTooltipAndAnchor = 8.dp, - ), - tooltip = { - RichTooltip( - caretShape = TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)), - action = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed(); onSkipTour() }) { - Text(stringResource(R.string.tour_skip)) - } - TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed() }) { - Text(stringResource(R.string.tour_next)) + if (addChannelTooltipState != null) { + LaunchedEffect(Unit) { + addChannelTooltipState.show() + } + LaunchedEffect(Unit) { + snapshotFlow { addChannelTooltipState.isVisible } + .dropWhile { !it } // skip initial false + .first { !it } // wait for dismiss (any cause) + onAddChannelTooltipDismissed() + } + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + RichTooltip( + caretShape = TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)), + action = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed(); onSkipTour() }) { + Text(stringResource(R.string.tour_skip)) + } + TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed() }) { + Text(stringResource(R.string.tour_next)) + } } } + ) { + Text(stringResource(R.string.tour_add_more_channels_hint)) } - ) { - Text(stringResource(R.string.tour_add_more_channels_hint)) - } - }, - state = addChannelTooltipState, - hasAction = true, - ) { + }, + state = addChannelTooltipState, + hasAction = true, + ) { + addChannelIcon() + } + } else { addChannelIcon() } - } else { - addChannelIcon() - } - if (isLoggedIn) { - IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { + if (isLoggedIn) { + IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.mentions_title), + tint = if (totalMentionCount > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + ) + } + } + IconButton(onClick = { + overflowInitialMenu = AppBarMenu.Main + showOverflowMenu = !showOverflowMenu + }) { Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.mentions_title), - tint = if (totalMentionCount > 0) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - } + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more) ) } } - IconButton(onClick = { - overflowInitialMenu = AppBarMenu.Main - showOverflowMenu = !showOverflowMenu - }) { - 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() - ) { - Surface( - shape = RoundedCornerShape( - topStart = 0.dp, - topEnd = 0.dp, - bottomStart = 12.dp, - bottomEnd = 12.dp - ), - color = MaterialTheme.colorScheme.surfaceContainer, + AnimatedVisibility( + visible = showOverflowMenu, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() ) { - InlineOverflowMenu( - isLoggedIn = isLoggedIn, - isStreamActive = currentStream != null, - hasStreamData = hasStreamData, - onDismiss = { - showOverflowMenu = false - overflowInitialMenu = AppBarMenu.Main - }, - initialMenu = overflowInitialMenu, - onAction = onAction, - ) + Surface( + shape = RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomStart = 12.dp, + bottomEnd = 12.dp + ), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + InlineOverflowMenu( + isLoggedIn = isLoggedIn, + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, + onDismiss = { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + }, + initialMenu = overflowInitialMenu, + onAction = onAction, + ) + } } } } } } } - } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index 96a86c6c3..4ea20c284 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.history.compose.MessageHistoryComposeViewModel import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams import com.flxrs.dankchat.chat.user.UserPopupStateParams @@ -24,7 +25,6 @@ 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.chat.history.compose.MessageHistoryComposeViewModel import com.flxrs.dankchat.main.compose.sheets.MentionSheet import com.flxrs.dankchat.main.compose.sheets.MessageHistorySheet import com.flxrs.dankchat.main.compose.sheets.RepliesSheet @@ -94,7 +94,7 @@ fun FullScreenSheetOverlay( // Use lastActiveState so content stays visible during the exit animation val renderState = when { isVisible -> sheetState - else -> lastActiveState + else -> lastActiveState } when (renderState) { @@ -125,6 +125,7 @@ fun FullScreenSheetOverlay( bottomContentPadding = bottomContentPadding, ) } + is FullScreenSheetState.Whisper -> { MentionSheet( mentionViewModel = mentionViewModel, @@ -151,6 +152,7 @@ fun FullScreenSheetOverlay( bottomContentPadding = bottomContentPadding, ) } + is FullScreenSheetState.Replies -> { RepliesSheet( rootMessageId = renderState.replyMessageId, @@ -172,6 +174,7 @@ fun FullScreenSheetOverlay( bottomContentPadding = bottomContentPadding, ) } + is FullScreenSheetState.History -> { MessageHistorySheet( viewModel = (currentHistoryViewModel ?: lastHistoryViewModel)!!, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index 8af15cfe6..5a418c085 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -10,10 +10,8 @@ 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.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight @@ -34,10 +32,10 @@ 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.graphics.Shape import androidx.compose.ui.res.stringResource -import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R @@ -78,8 +76,8 @@ fun MainAppBar( ) { var currentMenu by remember { mutableStateOf(null) } - TopAppBar( - title = { Text(stringResource(R.string.app_name)) }, actions = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, actions = { // Add channel button (always visible) IconButton(onClick = onAddChannel) { Icon( @@ -126,7 +124,7 @@ fun MainAppBar( ) { menu -> Column { when (menu) { - AppBarMenu.Main -> { + AppBarMenu.Main -> { if (!isLoggedIn) { DropdownMenuItem( text = { Text(stringResource(R.string.login)) }, @@ -233,7 +231,7 @@ fun MainAppBar( } } - AppBarMenu.Upload -> { + AppBarMenu.Upload -> { SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) DropdownMenuItem( text = { Text(stringResource(R.string.take_picture)) }, @@ -258,7 +256,7 @@ fun MainAppBar( ) } - AppBarMenu.More -> { + AppBarMenu.More -> { SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) DropdownMenuItem( text = { Text(stringResource(R.string.reload_emotes)) }, @@ -283,7 +281,7 @@ fun MainAppBar( ) } - null -> {} + null -> {} } } } @@ -341,7 +339,7 @@ fun ToolbarOverflowMenu( ) { menu -> Column { when (menu) { - AppBarMenu.Main -> { + AppBarMenu.Main -> { if (!isLoggedIn) { DropdownMenuItem( text = { Text(stringResource(R.string.login)) }, @@ -374,6 +372,7 @@ fun ToolbarOverflowMenu( onClick = { onOpenSettings(); onDismiss() } ) } + AppBarMenu.Account -> { SubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) DropdownMenuItem( @@ -385,6 +384,7 @@ fun ToolbarOverflowMenu( onClick = { onLogout(); onDismiss() } ) } + AppBarMenu.Channel -> { SubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) DropdownMenuItem( @@ -410,7 +410,8 @@ fun ToolbarOverflowMenu( ) } } - AppBarMenu.Upload -> { + + AppBarMenu.Upload -> { SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) DropdownMenuItem( text = { Text(stringResource(R.string.take_picture)) }, @@ -425,7 +426,8 @@ fun ToolbarOverflowMenu( onClick = { onChooseMedia(); onDismiss() } ) } - AppBarMenu.More -> { + + AppBarMenu.More -> { SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) DropdownMenuItem( text = { Text(stringResource(R.string.reload_emotes)) }, @@ -440,7 +442,8 @@ fun ToolbarOverflowMenu( onClick = { onClearChat(); onDismiss() } ) } - null -> {} + + null -> {} } } } @@ -492,7 +495,7 @@ fun InlineOverflowMenu( ) { menu -> Column { when (menu) { - AppBarMenu.Main -> { + AppBarMenu.Main -> { if (!isLoggedIn) { InlineMenuItem(text = stringResource(R.string.login)) { onAction(ToolbarAction.Login); onDismiss() } } else { @@ -504,11 +507,13 @@ fun InlineOverflowMenu( InlineMenuItem(text = stringResource(R.string.more), hasSubMenu = true) { currentMenu = AppBarMenu.More } InlineMenuItem(text = stringResource(R.string.settings)) { onAction(ToolbarAction.OpenSettings); onDismiss() } } + AppBarMenu.Account -> { InlineSubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) InlineMenuItem(text = stringResource(R.string.relogin)) { onAction(ToolbarAction.Relogin); onDismiss() } InlineMenuItem(text = stringResource(R.string.logout)) { onAction(ToolbarAction.Logout); onDismiss() } } + AppBarMenu.Channel -> { InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) if (hasStreamData || isStreamActive) { @@ -522,13 +527,15 @@ fun InlineOverflowMenu( InlineMenuItem(text = stringResource(R.string.block_channel)) { onAction(ToolbarAction.BlockChannel); onDismiss() } } } - AppBarMenu.Upload -> { + + AppBarMenu.Upload -> { InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) InlineMenuItem(text = stringResource(R.string.take_picture)) { onAction(ToolbarAction.CaptureImage); onDismiss() } InlineMenuItem(text = stringResource(R.string.record_video)) { onAction(ToolbarAction.CaptureVideo); onDismiss() } InlineMenuItem(text = stringResource(R.string.choose_media)) { onAction(ToolbarAction.ChooseMedia); onDismiss() } } - AppBarMenu.More -> { + + AppBarMenu.More -> { InlineSubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) InlineMenuItem(text = stringResource(R.string.reload_emotes)) { onAction(ToolbarAction.ReloadEmotes); onDismiss() } InlineMenuItem(text = stringResource(R.string.reconnect)) { onAction(ToolbarAction.Reconnect); onDismiss() } @@ -585,4 +592,4 @@ private fun InlineSubMenuHeader(title: String, onBack: () -> Unit) { color = MaterialTheme.colorScheme.primary ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index 9fa0e7099..23304933b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -1,7 +1,6 @@ package com.flxrs.dankchat.main.compose import android.content.ClipData -import com.flxrs.dankchat.main.compose.dialogs.ConfirmationDialog import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHostState @@ -22,10 +21,11 @@ import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsState import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel import com.flxrs.dankchat.chat.user.compose.UserPopupDialog +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.DisplayName import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog +import com.flxrs.dankchat.main.compose.dialogs.ConfirmationDialog import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index 39b93cb79..b55f254da 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -3,7 +3,6 @@ package com.flxrs.dankchat.main.compose import android.content.ClipData import android.content.ClipboardManager import android.content.res.Resources -import androidx.core.content.getSystemService import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult @@ -11,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.core.content.getSystemService import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.auth.AuthEvent @@ -41,8 +41,8 @@ fun MainScreenEventHandler( mainEventBus.events.collect { event -> when (event) { is MainEvent.LogOutRequested -> dialogViewModel.showLogout() - is MainEvent.UploadLoading -> dialogViewModel.setUploading(true) - is MainEvent.UploadSuccess -> { + is MainEvent.UploadLoading -> dialogViewModel.setUploading(true) + is MainEvent.UploadSuccess -> { dialogViewModel.setUploading(false) context.getSystemService() ?.setPrimaryClip(ClipData.newPlainText("dankchat_media_url", event.url)) @@ -56,19 +56,22 @@ fun MainScreenEventHandler( chatInputViewModel.insertText(event.url) } } - is MainEvent.UploadFailed -> { + + 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.Long) } - is MainEvent.OpenChannel -> { + + is MainEvent.OpenChannel -> { channelTabViewModel.selectTab( preferenceStore.channels.indexOf(event.channel) ) (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) } - else -> Unit + + else -> Unit } } } @@ -77,19 +80,22 @@ fun MainScreenEventHandler( LaunchedEffect(Unit) { authStateCoordinator.events.collect { event -> when (event) { - is AuthEvent.LoggedIn -> { + is AuthEvent.LoggedIn -> { snackbarHostState.showSnackbar( message = resources.getString(R.string.snackbar_login, event.userName), duration = SnackbarDuration.Short, ) } + is AuthEvent.ScopesOutdated -> { dialogViewModel.showLoginOutdated(event.userName) } - AuthEvent.TokenInvalid -> { + + AuthEvent.TokenInvalid -> { dialogViewModel.showLoginExpired() } - AuthEvent.ValidationFailed -> { + + AuthEvent.ValidationFailed -> { snackbarHostState.showSnackbar( message = resources.getString(R.string.oauth_verify_failed), duration = SnackbarDuration.Short, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index 01f008eb8..779b2ac63 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -3,10 +3,10 @@ package com.flxrs.dankchat.main.compose 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.UserStateRepository import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.domain.ChannelDataCoordinator -import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.appearance.InputAction @@ -83,8 +83,13 @@ class MainScreenViewModel( private val _keyboardHeightPx = MutableStateFlow(0) val keyboardHeightPx: StateFlow = _keyboardHeightPx.asStateFlow() - fun setGestureInputHidden(hidden: Boolean) { _gestureInputHidden.value = hidden } - fun setGestureToolbarHidden(hidden: Boolean) { _gestureToolbarHidden.value = hidden } + fun setGestureInputHidden(hidden: Boolean) { + _gestureInputHidden.value = hidden + } + + fun setGestureToolbarHidden(hidden: Boolean) { + _gestureToolbarHidden.value = hidden + } fun resetGestureState() { _gestureInputHidden.value = false diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt index eeabc71f0..8513f90a1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember @@ -111,7 +110,7 @@ fun QuickActionsMenu( onClick = { when { tourState.configureActionsTooltipState != null -> tourState.onAdvance?.invoke() - else -> onConfigureClick() + else -> onConfigureClick() } }, leadingIcon = { @@ -142,7 +141,7 @@ fun QuickActionsMenu( } } - else -> configureItem() + else -> configureItem() } } } @@ -161,7 +160,7 @@ private fun getOverflowItem( isFullscreen: Boolean, isModerator: Boolean, ): OverflowItem? = when (action) { - InputAction.Search -> OverflowItem( + InputAction.Search -> OverflowItem( labelRes = R.string.input_action_search, icon = Icons.Default.Search, ) @@ -171,30 +170,30 @@ private fun getOverflowItem( icon = Icons.Default.History, ) - InputAction.Stream -> when { + InputAction.Stream -> when { hasStreamData || isStreamActive -> OverflowItem( labelRes = if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream, icon = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, ) - else -> null + else -> null } - InputAction.RoomState -> when { + InputAction.RoomState -> when { isModerator -> OverflowItem( labelRes = R.string.menu_room_state, icon = Icons.Default.Shield, ) - else -> null + else -> null } - InputAction.Fullscreen -> OverflowItem( + 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( + InputAction.HideInput -> OverflowItem( labelRes = R.string.menu_hide_input, icon = Icons.Default.VisibilityOff, ) @@ -202,8 +201,8 @@ private fun getOverflowItem( private fun isActionEnabled(action: InputAction, inputEnabled: Boolean, hasLastMessage: Boolean): Boolean = when (action) { InputAction.Search, InputAction.Fullscreen, InputAction.HideInput -> true - InputAction.LastMessage -> inputEnabled && hasLastMessage - InputAction.Stream, InputAction.RoomState -> inputEnabled + InputAction.LastMessage -> inputEnabled && hasLastMessage + InputAction.Stream, InputAction.RoomState -> inputEnabled } /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt index 21bd599a0..909b904dd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt @@ -61,31 +61,36 @@ class SheetNavigationViewModel : ViewModel() { fun handleBackPress(): Boolean { return when { - _inputSheetState.value != InputSheetState.Closed -> { + _inputSheetState.value != InputSheetState.Closed -> { closeInputSheet() true } + _fullScreenSheetState.value != FullScreenSheetState.Closed -> { closeFullScreenSheet() true } - else -> false + + else -> false } } } sealed interface FullScreenSheetState { data object Closed : FullScreenSheetState - @Immutable data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState + @Immutable + data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState data object Mention : FullScreenSheetState data object Whisper : FullScreenSheetState - @Immutable data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState + @Immutable + data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState } sealed interface InputSheetState { data object Closed : InputSheetState data object EmoteMenu : InputSheetState - @Immutable data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState + @Immutable + data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState } @Immutable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt index d12ec48df..e630575e2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -82,7 +82,7 @@ fun StreamView( ) { val webViewModifier = when { isInPipMode || fillPane -> Modifier.fillMaxSize() - else -> Modifier + else -> Modifier .fillMaxWidth() .aspectRatio(16f / 9f) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt index 29c8aba3a..4c8b9e160 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt @@ -2,9 +2,9 @@ package com.flxrs.dankchat.main.compose import android.annotation.SuppressLint import android.app.Application +import androidx.compose.runtime.Immutable import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.repo.stream.StreamDataRepository @@ -13,7 +13,6 @@ 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.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt index 633768799..21335b432 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt @@ -65,7 +65,7 @@ fun AddChannelDialog( } } ) - + LaunchedEffect(Unit) { focusRequester.requestFocus() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt index 1450cd2fb..83e95aa81 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt @@ -60,7 +60,7 @@ fun EditChannelDialog( }), trailingIcon = if (renameText.text.isNotEmpty()) { { - IconButton(onClick = { + IconButton(onClick = { onRename(channelWithRename.channel, null) onDismiss() }) { @@ -90,7 +90,7 @@ fun EditChannelDialog( } } ) - + LaunchedEffect(Unit) { focusRequester.requestFocus() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt index 0562f20ee..0c9360b9f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt @@ -21,10 +21,10 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -39,8 +39,6 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.emote.EmoteSheetItem import kotlinx.coroutines.launch -import androidx.compose.material3.PrimaryTabRow - @OptIn(ExperimentalMaterial3Api::class) @Composable fun EmoteInfoDialog( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt index 451ff4834..f9287baee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt @@ -52,7 +52,7 @@ fun ManageChannelsDialog( ) { var channelToDelete by remember { mutableStateOf(null) } var channelToEdit by remember { mutableStateOf(null) } - + // Local state for smooth reordering and deferred updates val localChannels = remember { mutableStateListOf() } LaunchedEffect(channels) { @@ -85,7 +85,7 @@ fun ManageChannelsDialog( itemsIndexed(localChannels, key = { _, it -> it.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 = if (isDragging) MaterialTheme.colorScheme.surfaceContainerHighest else Color.Transparent @@ -115,7 +115,7 @@ fun ManageChannelsDialog( } } } - + if (localChannels.isEmpty()) { item { Text( @@ -146,7 +146,7 @@ fun ManageChannelsDialog( channelToEdit?.let { channel -> EditChannelDialog( channelWithRename = channel, - onRename = { userName, newName -> + onRename = { userName, newName -> val index = localChannels.indexOfFirst { it.channel == userName } if (index != -1) { val rename = newName?.ifBlank { null }?.let { UserName(it) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt index 54a5ff71a..8cad9b36e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt @@ -2,18 +2,16 @@ package com.flxrs.dankchat.main.compose.dialogs 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.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.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.AlertDialog @@ -24,10 +22,10 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -97,7 +95,7 @@ fun MessageOptionsDialog( } ) } - + if (canJump && channel != null) { MessageOptionItem( icon = Icons.AutoMirrored.Filled.OpenInNew, @@ -134,25 +132,25 @@ fun MessageOptionsDialog( if (canModerate) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - + MessageOptionItem( icon = Icons.Default.Timer, text = stringResource(R.string.user_popup_timeout), onClick = { showTimeoutDialog = true } ) - + MessageOptionItem( icon = Icons.Default.Delete, text = stringResource(R.string.user_popup_delete), onClick = { showDeleteDialog = true } ) - + MessageOptionItem( icon = Icons.Default.Gavel, text = stringResource(R.string.user_popup_ban), onClick = { showBanDialog = true } ) - + MessageOptionItem( icon = Icons.Default.Gavel, // Using same icon for unban text = stringResource(R.string.user_popup_unban), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt index 3a788c1d1..9af7dbbbe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt @@ -52,6 +52,7 @@ fun RoomStateDialog( "30", "/slow" ) + ParameterDialogType.FOLLOWER_MODE -> listOf( R.string.room_state_follower_only, R.string.minutes, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt index 6c7dab9ac..662e1d088 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt @@ -87,10 +87,10 @@ fun EmoteMenu( 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.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) + EmoteMenuTab.GLOBAL -> stringResource(R.string.emote_menu_tab_global) } ) } @@ -134,20 +134,20 @@ fun EmoteMenu( items = items, key = { item -> when (item) { - is EmoteItem.Emote -> "emote-${item.emote.id}-${item.emote.code}" + 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) + is EmoteItem.Emote -> GridItemSpan(1) } }, contentType = { item -> when (item) { is EmoteItem.Header -> "header" - is EmoteItem.Emote -> "emote" + is EmoteItem.Emote -> "emote" } } ) { item -> @@ -162,7 +162,7 @@ fun EmoteMenu( ) } - is EmoteItem.Emote -> { + is EmoteItem.Emote -> { AsyncImage( model = item.emote.url, contentDescription = item.emote.code, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt index 15c75c6fe..c853789dc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt @@ -66,10 +66,10 @@ fun EmoteMenuSheet( 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.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) + EmoteMenuTab.GLOBAL -> stringResource(R.string.emote_menu_tab_global) } ) } @@ -94,20 +94,20 @@ fun EmoteMenuSheet( items = items, key = { item -> when (item) { - is EmoteItem.Emote -> "emote-${item.emote.id}-${item.emote.code}" + 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) + is EmoteItem.Emote -> GridItemSpan(1) } }, contentType = { item -> when (item) { is EmoteItem.Header -> "header" - is EmoteItem.Emote -> "emote" + is EmoteItem.Emote -> "emote" } } ) { item -> @@ -122,7 +122,7 @@ fun EmoteMenuSheet( ) } - is EmoteItem.Emote -> { + is EmoteItem.Emote -> { AsyncImage( model = item.emote.url, contentDescription = item.emote.code, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index fe847a76c..c6bdea395 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -137,54 +137,54 @@ fun MentionSheet( .padding(bottom = 16.dp) .padding(horizontal = 8.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top - ) { - // Back navigation pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) + // Back navigation pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } } - } - // Tab pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - 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, - ) + // Tab pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + 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, + ) + } } } } } } - } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt index 68e71f6e5..443686ffa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -42,8 +42,8 @@ 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.lerp import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -59,11 +59,9 @@ import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.chat.history.compose.MessageHistoryComposeViewModel -import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.main.compose.SuggestionDropdown import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.chat.compose.ChatDisplaySettings +import com.flxrs.dankchat.main.compose.SuggestionDropdown import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index 58db59f79..a271602ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -112,37 +112,37 @@ fun RepliesSheet( .padding(bottom = 16.dp) .padding(horizontal = 8.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top - ) { - // Back navigation pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) + // Back navigation pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } } - } - // Title pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = stringResource(R.string.replies_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - ) + // Title pill + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.padding(start = 8.dp) + ) { + 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/onboarding/OnboardingDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt index 155180b5c..dab65442f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt @@ -8,10 +8,10 @@ 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.launch 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt index 8f9f3080a..d280fddcd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt @@ -2,8 +2,8 @@ package com.flxrs.dankchat.onboarding import android.Manifest import android.annotation.SuppressLint -import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -12,7 +12,6 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -22,7 +21,6 @@ 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.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons @@ -54,16 +52,14 @@ 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 com.flxrs.dankchat.utils.compose.buildLinkAnnotation import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import android.content.pm.PackageManager 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 @@ -398,7 +394,7 @@ private fun NotificationsPage( } } - NotificationPermissionState.Denied -> { + NotificationPermissionState.Denied -> { Text( text = stringResource(R.string.onboarding_notifications_rationale), style = MaterialTheme.typography.bodySmall, 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 4126d2230..cce087a17 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -7,9 +7,9 @@ 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.auth.AuthDataStore import com.flxrs.dankchat.auth.AuthSettings +import com.flxrs.dankchat.changelog.DankChatVersion import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName 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 index 95af1a695..c884f5fcb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -37,7 +37,6 @@ 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.core.app.ActivityCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R @@ -251,12 +250,12 @@ data class ThemeState( @Composable @Stable private fun rememberThemeState(theme: ThemePreference, trueDark: Boolean, systemDarkMode: Boolean): ThemeState { - val context = LocalContext.current + LocalContext.current val defaultEntries = stringArrayResource(R.array.theme_entries).toImmutableList() // minSdk 30 always supports light mode and system dark mode - val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) - val lightThemeTitle = stringResource(R.string.preference_light_theme_entry_title) - + 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() } 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 f28376b8c..2237850f0 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 @@ -1,5 +1,6 @@ package com.flxrs.dankchat.preferences.appearance +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted @@ -7,7 +8,6 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import androidx.compose.runtime.Immutable import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @@ -27,13 +27,13 @@ class AppearanceSettingsViewModel( 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.CheckeredMessages -> dataStore.update { it.copy(checkeredMessages = interaction.value) } - is AppearanceSettingsInteraction.AutoDisableInput -> dataStore.update { it.copy(autoDisableInput = 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.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) } } 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..ae789c481 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 @@ -59,29 +59,29 @@ class ChatSettingsDataStore( 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 -> { + 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) } - 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( + 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( + 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, @@ -90,7 +90,7 @@ class ChatSettingsDataStore( sharedChatMigration = true, ) - ChatPreferenceKeys.VisibleEmotes -> acc.copy( + ChatPreferenceKeys.VisibleEmotes -> acc.copy( visibleEmotes = value.mappedStringSetOrDefault( original = context.resources.getStringArray(R.array.emotes_entry_values), enumEntries = VisibleThirdPartyEmotes.entries, @@ -98,9 +98,9 @@ class ChatSettingsDataStore( ) ) - ChatPreferenceKeys.UnlistedEmotes -> acc.copy(allowUnlistedSevenTvEmotes = value.booleanOrDefault(acc.allowUnlistedSevenTvEmotes)) - ChatPreferenceKeys.LiveUpdates -> acc.copy(sevenTVLiveEmoteUpdates = value.booleanOrDefault(acc.sevenTVLiveEmoteUpdates)) - ChatPreferenceKeys.LiveUpdatesTimeout -> acc.copy( + 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, @@ -125,6 +125,7 @@ class ChatSettingsDataStore( visibleBadges = currentData.visibleBadges.plus(VisibleBadges.SharedChat).distinct(), sharedChatMigration = true, ) + override suspend fun cleanUp() = Unit } 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 878db0cea..6b77ab0bb 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 @@ -1,8 +1,8 @@ package com.flxrs.dankchat.preferences.chat +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow 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 index bea12c030..90765d5c1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -35,7 +35,6 @@ 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 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..3f7b9c39c 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 @@ -32,9 +32,9 @@ class NotificationsSettingsDataStore( 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( + 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 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..38aefa3f2 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 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 3acc40998..4220bd5a5 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 @@ -445,7 +445,7 @@ private fun MessageHighlightItem( ) } } - val defaultColor = when(item.type) { + 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) @@ -487,7 +487,7 @@ private fun MessageHighlightItem( .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), ) - Row ( + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { @@ -594,7 +594,7 @@ private fun UserHighlightItem( .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), ) - Row ( + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { @@ -659,15 +659,15 @@ 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 @@ -733,7 +733,7 @@ private fun BadgeHighlightItem( .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), ) - Row ( + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { 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..8ffe781b2 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 @@ -67,13 +67,13 @@ class HighlightsViewModel( HighlightsTab.Users -> { val entity = highlightsRepository.addUserHighlight() - userHighlights += entity.toItem(notificationsEnabled) + userHighlights += entity.toItem(notificationsEnabled) position = userHighlights.lastIndex } HighlightsTab.Badges -> { val entity = highlightsRepository.addBadgeHighlight() - badgeHighlights += entity.toItem(notificationsEnabled) + badgeHighlights += entity.toItem(notificationsEnabled) position = badgeHighlights.lastIndex } @@ -101,7 +101,7 @@ class HighlightsViewModel( isLast = position == userHighlights.lastIndex } - is BadgeHighlightItem -> { + is BadgeHighlightItem -> { highlightsRepository.updateBadgeHighlight(item.toEntity()) badgeHighlights.add(position, item) isLast = position == badgeHighlights.lastIndex @@ -131,7 +131,7 @@ class HighlightsViewModel( userHighlights.removeAt(position) } - is BadgeHighlightItem -> { + is BadgeHighlightItem -> { position = badgeHighlights.indexOfFirst { it.id == item.id } highlightsRepository.removeBadgeHighlight(item.toEntity()) badgeHighlights.removeAt(position) 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 2d83170db..c7b0dcec2 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 @@ -346,7 +346,7 @@ private fun IgnoresList( item(key = "bottom-spacer") { val height = when (tab) { IgnoresTab.Messages, IgnoresTab.Users -> 112.dp - IgnoresTab.Twitch -> Dp.Unspecified + IgnoresTab.Twitch -> Dp.Unspecified } NavigationBarSpacer(Modifier.height(height)) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt index b92d8f211..4efbe4c33 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt @@ -5,7 +5,6 @@ import android.content.ClipboardManager import android.content.Intent import android.net.Uri import android.os.Bundle -import com.flxrs.dankchat.utils.extensions.parcelable import android.webkit.MimeTypeMap import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -49,6 +48,7 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.data.repo.data.DataRepository import com.flxrs.dankchat.theme.DankChatTheme import com.flxrs.dankchat.utils.createMediaFile +import com.flxrs.dankchat.utils.extensions.parcelable import com.flxrs.dankchat.utils.removeExifAttributes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -168,7 +168,7 @@ private fun ShareUploadDialog( when (currentState) { is ShareUploadState.Loading -> LoadingContent() is ShareUploadState.Success -> SuccessContent(url = currentState.url) - is ShareUploadState.Error -> ErrorContent(message = currentState.message) + is ShareUploadState.Error -> ErrorContent(message = currentState.message) } } @@ -178,7 +178,7 @@ private fun ShareUploadDialog( verticalAlignment = Alignment.CenterVertically, ) { when (state) { - is ShareUploadState.Error -> { + is ShareUploadState.Error -> { TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_dismiss)) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt index aacae4f01..df1fe7899 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt @@ -42,9 +42,9 @@ class FeatureTourController( val currentStep: TourStep? get() = when { - !isActive -> null + !isActive -> null currentStepIndex >= TourStep.entries.size -> null - else -> TourStep.entries[currentStepIndex] + else -> TourStep.entries[currentStepIndex] } /** When true, ChatInputLayout should force the overflow menu open. */ @@ -74,7 +74,7 @@ class FeatureTourController( // A larger gap means a prior tour was never completed and the step index is stale. currentStepIndex = when { CURRENT_TOUR_VERSION - settings.featureTourVersion == 1 -> settings.featureTourStep.coerceIn(0, TourStep.entries.size - 1) - else -> 0 + else -> 0 } applyStepSideEffects() showCurrentTooltip() @@ -95,7 +95,8 @@ class FeatureTourController( completeTour() return } - else -> { + + else -> { onboardingDataStore.updateAsync { it.copy(featureTourStep = currentStepIndex) } applyStepSideEffects() } @@ -114,9 +115,9 @@ class FeatureTourController( private fun applyStepSideEffects() { when (currentStep) { TourStep.ConfigureActions -> forceOverflowOpen = true - TourStep.SwipeGesture -> forceOverflowOpen = false - TourStep.RecoveryFab -> onHideInput?.invoke() - else -> {} + TourStep.SwipeGesture -> forceOverflowOpen = false + TourStep.RecoveryFab -> onHideInput?.invoke() + else -> {} } } @@ -145,11 +146,11 @@ class FeatureTourController( } private fun tooltipStateForStep(step: TourStep): TooltipState = when (step) { - TourStep.InputActions -> inputActionsTooltipState - TourStep.OverflowMenu -> overflowMenuTooltipState - TourStep.ConfigureActions -> configureActionsTooltipState - TourStep.SwipeGesture -> swipeGestureTooltipState - TourStep.RecoveryFab -> recoveryFabTooltipState + TourStep.InputActions -> inputActionsTooltipState + TourStep.OverflowMenu -> overflowMenuTooltipState + TourStep.ConfigureActions -> configureActionsTooltipState + TourStep.SwipeGesture -> swipeGestureTooltipState + TourStep.RecoveryFab -> recoveryFabTooltipState } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt index 3e43ed1cd..3af647070 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt @@ -55,7 +55,7 @@ class PostOnboardingCoordinator( val settings = onboardingDataStore.current() step = when { settings.featureTourVersion < CURRENT_TOUR_VERSION && !isEmpty -> PostOnboardingStep.FeatureTour - else -> PostOnboardingStep.Complete + else -> PostOnboardingStep.Complete } } @@ -69,11 +69,11 @@ class PostOnboardingCoordinator( val toolbarDone = settings.hasShownToolbarHint || toolbarHintDone step = when { - !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle - isEmpty -> PostOnboardingStep.Idle - !toolbarDone -> PostOnboardingStep.ToolbarPlusHint + !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle + isEmpty -> PostOnboardingStep.Idle + !toolbarDone -> PostOnboardingStep.ToolbarPlusHint settings.featureTourVersion < CURRENT_TOUR_VERSION -> PostOnboardingStep.FeatureTour - else -> PostOnboardingStep.Complete + else -> PostOnboardingStep.Complete } } } 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..2c83884e6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt @@ -5,7 +5,8 @@ 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 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 index e1c615c36..54a05b1d6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -12,7 +12,6 @@ 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 androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.layout.onGloballyPositioned @@ -20,6 +19,7 @@ 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 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..16f229d77 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 @@ -57,6 +57,7 @@ fun > Any?.mappedStringOrDefault(original: Array, enumEntrie @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 { From 5cb636a46637b4fef5f804d97cefd3c8628ce8ca Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 14:53:46 +0100 Subject: [PATCH 061/349] feat(compose): Add rounded corners to highlight message backgrounds --- .../chat/compose/ChatMessageMapper.kt | 2 ++ .../chat/compose/ChatMessageUiState.kt | 10 ++++++ .../flxrs/dankchat/chat/compose/ChatScreen.kt | 34 +++++++++++++++---- .../chat/compose/messages/PrivMessage.kt | 6 +++- .../chat/compose/messages/SystemMessages.kt | 6 +++- .../compose/messages/WhisperAndRedemption.kt | 6 +++- 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 3af5bc370..97fcdb41b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -231,6 +231,7 @@ class ChatMessageMapper( lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, + isHighlighted = shouldHighlight, message = message, displayName = displayName, rawNameColor = rawNameColor, @@ -397,6 +398,7 @@ class ChatMessageMapper( darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, enableRipple = true, + isHighlighted = highlights.isNotEmpty(), channel = channel, userId = userId, userName = name, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index f9107b871..7de2f7423 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -24,6 +24,7 @@ sealed interface ChatMessageUiState { val darkBackgroundColor: Color val textAlpha: Float val enableRipple: Boolean + val isHighlighted: Boolean /** * Regular chat message from a user @@ -37,6 +38,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean, + override val isHighlighted: Boolean, val channel: UserName, val userId: UserId?, val userName: UserName, @@ -63,6 +65,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, val message: TextResource, ) : ChatMessageUiState @@ -78,6 +81,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, val message: String, ) : ChatMessageUiState @@ -93,6 +97,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, val message: String, val displayName: String = "", val rawNameColor: Int = Message.DEFAULT_COLOR, @@ -111,6 +116,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, val message: TextResource, ) : ChatMessageUiState @@ -126,6 +132,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = true, val nameText: String?, val title: String, val cost: Int, @@ -145,6 +152,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color = Color.Transparent, override val textAlpha: Float = 0.5f, override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, val dateText: String, ) : ChatMessageUiState @@ -160,6 +168,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, val heldMessageId: String, val channel: UserName, val badges: ImmutableList, @@ -184,6 +193,7 @@ sealed interface ChatMessageUiState { override val darkBackgroundColor: Color, override val textAlpha: Float, override val enableRipple: Boolean, + override val isHighlighted: Boolean = false, val userId: UserId, val userName: UserName, val displayName: DisplayName, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 50dac41ee..689267834 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -9,6 +9,9 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -18,6 +21,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew @@ -168,10 +172,10 @@ fun ChatScreen( .fillMaxSize() .then(scrollModifier) ) { - items( + itemsIndexed( items = reversedMessages, - key = { message -> message.id }, - contentType = { message -> + key = { _, message -> message.id }, + contentType = { _, message -> when (message) { is ChatMessageUiState.SystemMessageUi -> "system" is ChatMessageUiState.NoticeMessageUi -> "notice" @@ -184,9 +188,14 @@ fun ChatScreen( is ChatMessageUiState.DateSeparatorUi -> "datesep" } } - ) { message -> + ) { index, message -> + // reverseLayout=true: index 0 = bottom (newest), index+1 = visually above + val highlightedBelow = reversedMessages.getOrNull(index - 1)?.isHighlighted == true + val highlightedAbove = reversedMessages.getOrNull(index + 1)?.isHighlighted == true + val highlightShape = message.highlightShape(highlightedAbove, highlightedBelow) ChatMessageItem( message = message, + highlightShape = highlightShape, fontSize = fontSize, showChannelPrefix = showChannelPrefix, animateGifs = animateGifs, @@ -311,12 +320,23 @@ private fun RecoveryFab( } } +private val HIGHLIGHT_CORNER_RADIUS = 8.dp + +private fun ChatMessageUiState.highlightShape(highlightedAbove: Boolean, highlightedBelow: Boolean): Shape { + if (!isHighlighted) return RectangleShape + val top = if (highlightedAbove) 0.dp else HIGHLIGHT_CORNER_RADIUS + val bottom = if (highlightedBelow) 0.dp else HIGHLIGHT_CORNER_RADIUS + 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, @@ -342,6 +362,7 @@ private fun ChatMessageItem( is ChatMessageUiState.UserNoticeMessageUi -> UserNoticeMessageComposable( message = message, + highlightShape = highlightShape, fontSize = fontSize ) @@ -359,14 +380,13 @@ private fun ChatMessageItem( is ChatMessageUiState.PrivMessageUi -> { if (onJumpToMessage != null) { - val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.background(backgroundColor), ) { Box(modifier = Modifier.weight(1f)) { PrivMessageComposable( message = message, + highlightShape = highlightShape, fontSize = fontSize, showChannelPrefix = showChannelPrefix, animateGifs = animateGifs, @@ -391,6 +411,7 @@ private fun ChatMessageItem( } else { PrivMessageComposable( message = message, + highlightShape = highlightShape, fontSize = fontSize, showChannelPrefix = showChannelPrefix, animateGifs = animateGifs, @@ -404,6 +425,7 @@ private fun ChatMessageItem( is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( message = message, + highlightShape = highlightShape, fontSize = fontSize ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 339e1674c..bb60c496e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -3,6 +3,8 @@ package com.flxrs.dankchat.chat.compose.messages import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.clickable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource @@ -64,6 +66,7 @@ import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @Composable fun PrivMessageComposable( message: ChatMessageUiState.PrivMessageUi, + highlightShape: Shape = RectangleShape, fontSize: Float, modifier: Modifier = Modifier, showChannelPrefix: Boolean = false, @@ -80,7 +83,8 @@ fun PrivMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .background(backgroundColor) + .background(backgroundColor, highlightShape) + .then(if (message.isHighlighted) Modifier.padding(horizontal = 2.dp) else Modifier) .indication(interactionSource, ripple()) .padding(vertical = 2.dp) ) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index ab5fe1d2c..1fb576402 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -3,6 +3,8 @@ package com.flxrs.dankchat.chat.compose.messages import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -79,6 +81,7 @@ fun NoticeMessageComposable( @Composable fun UserNoticeMessageComposable( message: ChatMessageUiState.UserNoticeMessageUi, + highlightShape: Shape = RectangleShape, fontSize: Float, modifier: Modifier = Modifier, ) { @@ -153,7 +156,8 @@ fun UserNoticeMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .background(bgColor) + .background(bgColor, highlightShape) + .then(if (message.isHighlighted) Modifier.padding(horizontal = 2.dp) else Modifier) .padding(vertical = 2.dp) .alpha(message.textAlpha) ) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 22dd72d53..201b8ebda 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -3,6 +3,8 @@ package com.flxrs.dankchat.chat.compose.messages import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -316,6 +318,7 @@ private fun WhisperMessageText( @Composable fun PointRedemptionMessageComposable( message: ChatMessageUiState.PointRedemptionMessageUi, + highlightShape: Shape = RectangleShape, fontSize: Float, modifier: Modifier = Modifier, ) { @@ -326,7 +329,8 @@ fun PointRedemptionMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .background(backgroundColor) + .background(backgroundColor, highlightShape) + .then(if (message.isHighlighted) Modifier.padding(horizontal = 2.dp) else Modifier) .padding(vertical = 2.dp) .alpha(message.textAlpha) ) { From a05a436e37c91a8c5d608a618b836bec0c52f5d2 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 15:06:41 +0100 Subject: [PATCH 062/349] fix(compose): Apply consistent horizontal padding and container-level alpha to all message types --- .../dankchat/chat/compose/messages/AutomodMessage.kt | 4 ++-- .../dankchat/chat/compose/messages/PrivMessage.kt | 12 ++++++------ .../dankchat/chat/compose/messages/SystemMessages.kt | 5 ++--- .../chat/compose/messages/WhisperAndRedemption.kt | 9 ++++----- .../messages/common/SimpleMessageContainer.kt | 4 ++-- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt index b48c02d96..7840472d5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt @@ -202,9 +202,9 @@ fun AutomodMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .drawBehind { drawRect(backgroundColor) } .alpha(resolvedAlpha) - .padding(vertical = 2.dp) + .drawBehind { drawRect(backgroundColor) } + .padding(horizontal = 2.dp, vertical = 2.dp) ) { // Header line with badge inline content TextWithMeasuredInlineContent( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index bb60c496e..21dcbda9f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -83,10 +83,10 @@ fun PrivMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() + .alpha(message.textAlpha) .background(backgroundColor, highlightShape) - .then(if (message.isHighlighted) Modifier.padding(horizontal = 2.dp) else Modifier) .indication(interactionSource, ripple()) - .padding(vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp) ) { // Reply thread header if (message.thread != null) { @@ -97,16 +97,17 @@ fun PrivMessageComposable( .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically ) { + val replyColor = rememberAdaptiveTextColor(backgroundColor).copy(alpha = 0.6f) Icon( imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = null, modifier = Modifier.size(16.dp), - tint = Color.Gray + tint = replyColor ) Text( text = "Reply to @${message.thread.userName}: ${message.thread.message}", fontSize = (fontSize * 0.9f).sp, - color = Color.Gray, + color = replyColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -320,8 +321,7 @@ private fun PrivMessageText( style = TextStyle(fontSize = fontSize.sp), knownDimensions = knownDimensions, modifier = Modifier - .fillMaxWidth() - .alpha(message.textAlpha), + .fillMaxWidth(), interactionSource = interactionSource, onTextClick = { offset -> // Handle username clicks diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index 1fb576402..43794dd14 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -156,10 +156,9 @@ fun UserNoticeMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .background(bgColor, highlightShape) - .then(if (message.isHighlighted) Modifier.padding(horizontal = 2.dp) else Modifier) - .padding(vertical = 2.dp) .alpha(message.textAlpha) + .background(bgColor, highlightShape) + .padding(horizontal = 2.dp, vertical = 2.dp) ) { ClickableText( text = annotatedString, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 201b8ebda..93bf54f8f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -76,10 +76,10 @@ fun WhisperMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() + .alpha(message.textAlpha) .background(backgroundColor) .indication(interactionSource, ripple()) - .padding(vertical = 2.dp) - .alpha(message.textAlpha) + .padding(horizontal = 2.dp, vertical = 2.dp) ) { Box(modifier = Modifier.weight(1f)) { WhisperMessageText( @@ -329,10 +329,9 @@ fun PointRedemptionMessageComposable( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .background(backgroundColor, highlightShape) - .then(if (message.isHighlighted) Modifier.padding(horizontal = 2.dp) else Modifier) - .padding(vertical = 2.dp) .alpha(message.textAlpha) + .background(backgroundColor, highlightShape) + .padding(horizontal = 2.dp, vertical = 2.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt index ed3b3d49d..79d3385fe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt @@ -75,9 +75,9 @@ fun SimpleMessageContainer( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .background(bgColor) - .padding(vertical = 2.dp) .alpha(textAlpha) + .background(bgColor) + .padding(horizontal = 2.dp, vertical = 2.dp) ) { ClickableText( text = annotatedString, From 0bc81706319e10b3f108c4c69277d813eef6272d Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 15:36:14 +0100 Subject: [PATCH 063/349] refactor(i18n): Replace suffix-based moderation strings with complete sentence variants --- .../data/twitch/message/ModerationMessage.kt | 200 ++++++++++-------- app/src/main/res/values-be-rBY/strings.xml | 66 +++--- app/src/main/res/values-ca/strings.xml | 72 +++---- app/src/main/res/values-cs/strings.xml | 66 +++--- app/src/main/res/values-de-rDE/strings.xml | 68 +++--- app/src/main/res/values-en-rAU/strings.xml | 58 ++--- app/src/main/res/values-en-rGB/strings.xml | 58 ++--- app/src/main/res/values-en/strings.xml | 58 ++--- app/src/main/res/values-es-rES/strings.xml | 72 +++---- app/src/main/res/values-fi-rFI/strings.xml | 61 +++--- app/src/main/res/values-fr-rFR/strings.xml | 72 +++---- app/src/main/res/values-hu-rHU/strings.xml | 59 +++--- app/src/main/res/values-it/strings.xml | 72 +++---- app/src/main/res/values-ja-rJP/strings.xml | 68 +++--- app/src/main/res/values-pl-rPL/strings.xml | 66 +++--- app/src/main/res/values-pt-rBR/strings.xml | 63 +++--- app/src/main/res/values-pt-rPT/strings.xml | 63 +++--- app/src/main/res/values-ru-rRU/strings.xml | 66 +++--- app/src/main/res/values-sr/strings.xml | 62 +++--- app/src/main/res/values-tr-rTR/strings.xml | 59 +++--- app/src/main/res/values-uk-rUA/strings.xml | 66 +++--- app/src/main/res/values/strings.xml | 71 ++++--- 22 files changed, 784 insertions(+), 782 deletions(-) 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 107957d11..1acea0cf1 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 @@ -71,25 +71,16 @@ data class ModerationMessage( RemovePermittedTerm, } - private val durationSuffix: TextResource - get() = duration?.let { TextResource.Res(R.string.mod_duration_suffix, persistentListOf(it)) } ?: TextResource.Plain("") - private val creatorSuffix: TextResource - get() = creatorUserDisplay?.let { TextResource.Res(R.string.mod_by_creator_suffix, persistentListOf(it.toString())) } ?: TextResource.Plain("") - private val quotedReasonSuffix: TextResource - get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reason_suffix, persistentListOf(it)) } ?: TextResource.Plain("") - private val reasonsSuffix: TextResource - get() = reason.takeUnless { it.isNullOrBlank() }?.let { TextResource.Res(R.string.mod_reasons_suffix, persistentListOf(it)) } ?: TextResource.Plain("") + private val hasReason get() = !reason.isNullOrBlank() private val quotedTermsOrBlank get() = reason.takeUnless { it.isNullOrBlank() } ?: "terms" - private fun sayingSuffix(showDeletedMessage: Boolean): TextResource { - if (!showDeletedMessage) return TextResource.Plain("") - + 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 TextResource.Res(R.string.mod_saying_suffix, persistentListOf(trimmed)) + }.takeIf { it.isNotEmpty() } } private fun countSuffix(): TextResource { @@ -99,97 +90,138 @@ data class ModerationMessage( } } - private fun minutesSuffix(): TextResource { - return durationInt?.takeIf { it > 0 }?.let { TextResource.PluralRes(R.plurals.mod_minutes_suffix, it, persistentListOf(it)) } ?: TextResource.Plain("") - } - - private fun secondsSuffix(): TextResource { - return durationInt?.let { TextResource.PluralRes(R.plurals.mod_seconds_suffix, it, persistentListOf(it)) } ?: TextResource.Plain("") - } - fun getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): TextResource { - return when (action) { - Action.Timeout -> when (targetUser) { - currentUser -> TextResource.Res(R.string.mod_timeout_self, persistentListOf(durationSuffix, creatorSuffix, quotedReasonSuffix, countSuffix())) + val creator = creatorUserDisplay.toString() + val target = targetUserDisplay.toString() + val dur = duration.orEmpty() + val source = sourceBroadcasterDisplay.toString() + + val message = when (action) { + Action.Timeout -> 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(targetUserDisplay.toString(), durationSuffix, countSuffix())) - else -> TextResource.Res(R.string.mod_timeout_by_creator, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, countSuffix())) + 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(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Ban -> when (targetUser) { - currentUser -> TextResource.Res(R.string.mod_ban_self, persistentListOf(creatorSuffix, quotedReasonSuffix)) + 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(targetUserDisplay.toString())) - else -> TextResource.Res(R.string.mod_ban_by_creator, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), quotedReasonSuffix)) + 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(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Mod -> TextResource.Res(R.string.mod_modded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unmod -> TextResource.Res(R.string.mod_unmodded, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Delete -> when (creatorUserDisplay) { - null -> TextResource.Res(R.string.mod_delete_no_creator, persistentListOf(targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) - else -> TextResource.Res(R.string.mod_delete_by_creator, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sayingSuffix(showDeletedMessage))) + 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) { + Action.Clear -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_clear_no_creator) - else -> TextResource.Res(R.string.mod_clear_by_creator, persistentListOf(creatorUserDisplay.toString())) + else -> TextResource.Res(R.string.mod_clear_by_creator, persistentListOf(creator)) } - Action.Vip -> TextResource.Res(R.string.mod_vip_added, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unvip -> TextResource.Res(R.string.mod_vip_removed, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Warn -> { - val suffix = when (val r = reasonsSuffix) { - is TextResource.Plain -> TextResource.Plain(".") - else -> r - } - TextResource.Res(R.string.mod_warn, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), suffix)) + 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(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.Unraid -> TextResource.Res(R.string.mod_unraid, persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString())) - Action.EmoteOnly -> TextResource.Res(R.string.mod_emote_only_on, persistentListOf(creatorUserDisplay.toString())) - Action.EmoteOnlyOff -> TextResource.Res(R.string.mod_emote_only_off, persistentListOf(creatorUserDisplay.toString())) - Action.Followers -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creatorUserDisplay.toString(), minutesSuffix())) - Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, persistentListOf(creatorUserDisplay.toString())) - Action.UniqueChat -> TextResource.Res(R.string.mod_unique_chat_on, persistentListOf(creatorUserDisplay.toString())) - Action.UniqueChatOff -> TextResource.Res(R.string.mod_unique_chat_off, persistentListOf(creatorUserDisplay.toString())) - Action.Slow -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creatorUserDisplay.toString(), secondsSuffix())) - Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, persistentListOf(creatorUserDisplay.toString())) - Action.Subscribers -> TextResource.Res(R.string.mod_subscribers_on, persistentListOf(creatorUserDisplay.toString())) - Action.SubscribersOff -> TextResource.Res(R.string.mod_subscribers_off, persistentListOf(creatorUserDisplay.toString())) - Action.SharedTimeout -> TextResource.Res( - R.string.mod_shared_timeout, - persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), durationSuffix, sourceBroadcasterDisplay.toString(), countSuffix()) - ) + 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)) - Action.SharedUntimeout -> TextResource.Res( - R.string.mod_shared_untimeout, - persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString()) - ) + Action.Followers -> when (val mins = durationInt?.takeIf { it > 0 }) { + null -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creator)) + else -> TextResource.Res(R.string.mod_followers_on_duration, persistentListOf(creator, mins)) + } - Action.SharedBan -> TextResource.Res( - R.string.mod_shared_ban, - persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), quotedReasonSuffix) - ) + 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)) - Action.SharedUnban -> TextResource.Res( - R.string.mod_shared_unban, - persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString()) - ) + Action.Slow -> when (val secs = durationInt) { + null -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creator)) + else -> TextResource.Res(R.string.mod_slow_on_duration, persistentListOf(creator, secs)) + } - Action.SharedDelete -> TextResource.Res( - R.string.mod_shared_delete, - persistentListOf(creatorUserDisplay.toString(), targetUserDisplay.toString(), sourceBroadcasterDisplay.toString(), sayingSuffix(showDeletedMessage)) - ) + 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)) + + Action.SharedTimeout -> 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)) + + Action.SharedDelete -> { + val msg = trimmedMessage(showDeletedMessage) + when (msg) { + 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)) + } - Action.AddBlockedTerm -> TextResource.Res(R.string.automod_moderation_added_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.AddPermittedTerm -> TextResource.Res(R.string.automod_moderation_added_permitted_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.RemoveBlockedTerm -> TextResource.Res(R.string.automod_moderation_removed_blocked_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) - Action.RemovePermittedTerm -> TextResource.Res(R.string.automod_moderation_removed_permitted_term, persistentListOf(creatorUserDisplay.toString(), quotedTermsOrBlank)) + val count = countSuffix() + return when (count) { + is TextResource.Plain -> message + else -> TextResource.Res(R.string.mod_message_with_count, persistentListOf(message, count)) } } diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 700fe3477..472693129 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -463,11 +463,17 @@ - Вас было заглушана%1$s%2$s%3$s.%4$s - %1$s заглушыў %2$s%3$s.%4$s - %1$s быў заглушаны%2$s.%3$s - Вас было забанена%1$s%2$s. - %1$s забаніў %2$s%3$s. + Вас было заглушана на %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. @@ -475,50 +481,42 @@ %1$s зняў мадэратара з %2$s. %1$s дадаў %2$s як VIP гэтага канала. %1$s выдаліў %2$s як VIP гэтага канала. - %1$s папярэдзіў %2$s%3$s + %1$s папярэдзіў %2$s. + %1$s папярэдзіў %2$s: %3$s %1$s пачаў рэйд на %2$s. %1$s адмяніў рэйд на %2$s. - %1$s выдаліў паведамленне ад %2$s%3$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 уключыў рэжым толькі для падпісчыкаў канала.%2$s + %1$s уключыў рэжым толькі для падпісчыкаў канала. + %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвілін). %1$s выключыў рэжым толькі для падпісчыкаў канала. %1$s уключыў рэжым унікальнага чату. %1$s выключыў рэжым унікальнага чату. - %1$s уключыў павольны рэжым.%2$s + %1$s уключыў павольны рэжым. + %1$s уключыў павольны рэжым (%2$d секунд). %1$s выключыў павольны рэжым. %1$s уключыў рэжым толькі для падпісчыкаў. %1$s выключыў рэжым толькі для падпісчыкаў. - %1$s заглушыў %2$s%3$s у %4$s.%5$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%4$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%4$s - на %1$s - ад %1$s - : \"%1$s\" - : %1$s - з тэкстам: \"%1$s\" + %1$s выдаліў паведамленне ад %2$s у %3$s. + %1$s выдаліў паведамленне ад %2$s у %3$s з тэкстам: \"%4$s\". + %1$s%2$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 секунд) + \u0020(%1$d раз) + \u0020(%1$d разы) + \u0020(%1$d разоў) + \u0020(%1$d разоў) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 734c296f7..e02875aab 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -349,68 +349,60 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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%1$s%2$s%3$s.%4$s - %1$s ha expulsat temporalment %2$s%3$s.%4$s - %1$s ha estat expulsat temporalment%2$s.%3$s - - Has estat banejat%1$s%2$s. - %1$s ha banejat %2$s%3$s. + + 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%3$s + %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%3$s. - Un missatge de %1$s ha estat eliminat%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.%2$s + %1$s ha activat el mode followers-only. + %1$s ha activat el mode followers-only (%2$d minuts). %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.%2$s + %1$s ha activat el mode slow. + %1$s ha activat el mode slow (%2$d segons). %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%3$s a %4$s.%5$s + %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%4$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%4$s - - per %1$s - per %1$s - : \"%1$s\" - : %1$s - dient: \"%1$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 - (%1$d vegada) - (%1$d vegades) - (%1$d vegades) - - - (%1$d minut) - (%1$d minuts) - (%1$d minuts) - - - (%1$d segon) - (%1$d segons) - (%1$d segons) + \u0020(%1$d vegada) + \u0020(%1$d vegades) + \u0020(%1$d vegades) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index de725ee5c..ec73ffbeb 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -464,11 +464,17 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade - Byl/a jste ztlumen/a%1$s%2$s%3$s.%4$s - %1$s ztlumil/a %2$s%3$s.%4$s - %1$s byl/a ztlumen/a%2$s.%3$s - Byl/a jste zabanován/a%1$s%2$s. - %1$s zabanoval/a %2$s%3$s. + 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. @@ -476,50 +482,42 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %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%3$s + %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%3$s. - Zpráva od %1$s byla smazá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í.%2$s + %1$s zapnul/a režim pouze pro sledující. + %1$s zapnul/a režim pouze pro sledující (%2$d minut). %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.%2$s + %1$s zapnul/a pomalý režim. + %1$s zapnul/a pomalý režim (%2$d sekund). %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%3$s v %4$s.%5$s + %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%4$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%4$s - na %1$s - od %1$s - : \"%1$s\" - : %1$s - s textem: \"%1$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 - (%1$d krát) - (%1$d krát) - (%1$d krát) - (%1$d krát) - - - (%1$d minuta) - (%1$d minuty) - (%1$d minut) - (%1$d minut) - - - (%1$d sekunda) - (%1$d sekundy) - (%1$d sekund) - (%1$d sekund) + \u0020(%1$d krát) + \u0020(%1$d krát) + \u0020(%1$d krát) + \u0020(%1$d krát) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index d402dadef..0c2110e8b 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -475,65 +475,59 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %1$s hat %2$s als blockierten Begriff von AutoMod entfernt. %1$s hat %2$s als erlaubten Begriff von AutoMod entfernt. - - - Du wurdest getimeouted%1$s%2$s%3$s.%4$s - %1$s hat %2$s getimeouted%3$s.%4$s - %1$s wurde getimeouted%2$s.%3$s - - Du wurdest gebannt%1$s%2$s. - %1$s hat %2$s gebannt%3$s. + + 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%3$s + %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%3$s. - Eine Nachricht von %1$s wurde gelöscht%2$s. - + %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.%2$s + %1$s hat den Followers-only-Modus aktiviert. + %1$s hat den Followers-only-Modus aktiviert (%2$d Minuten). %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.%2$s + %1$s hat den Slow-Modus aktiviert. + %1$s hat den Slow-Modus aktiviert (%2$d Sekunden). %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%3$s in %4$s getimeouted.%5$s + %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%4$s. + %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%4$s - - für %1$s - von %1$s - : \"%1$s\" - : %1$s - mit dem Inhalt: \"%1$s\" + %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 - (%1$d Mal) - (%1$d Mal) - - - (%1$d Minute) - (%1$d Minuten) - - - (%1$d Sekunde) - (%1$d Sekunden) + \u0020(%1$d Mal) + \u0020(%1$d Mal) diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 393bacf8e..8d7420e35 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -287,11 +287,17 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/ - You were timed out%1$s%2$s%3$s.%4$s - %1$s timed out %2$s%3$s.%4$s - %1$s has been timed out%2$s.%3$s - You were banned%1$s%2$s. - %1$s banned %2$s%3$s. + 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. @@ -299,44 +305,40 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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%3$s + %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%3$s. - A message from %1$s was deleted%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.%2$s + %1$s turned on followers-only mode. + %1$s turned on followers-only mode (%2$d minutes). %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.%2$s + %1$s turned on slow mode. + %1$s turned on slow mode (%2$d seconds). %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%3$s in %4$s.%5$s + %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%4$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%4$s - for %1$s - by %1$s - : \"%1$s\" - : %1$s - saying: \"%1$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 - (%1$d time) - (%1$d times) - - - (%1$d minute) - (%1$d minutes) - - - (%1$d second) - (%1$d seconds) + \u0020(%1$d time) + \u0020(%1$d times) Backspace diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index cec0801fd..8ff54464e 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -288,11 +288,17 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/ - You were timed out%1$s%2$s%3$s.%4$s - %1$s timed out %2$s%3$s.%4$s - %1$s has been timed out%2$s.%3$s - You were banned%1$s%2$s. - %1$s banned %2$s%3$s. + 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. @@ -300,44 +306,40 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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%3$s + %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%3$s. - A message from %1$s was deleted%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.%2$s + %1$s turned on followers-only mode. + %1$s turned on followers-only mode (%2$d minutes). %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.%2$s + %1$s turned on slow mode. + %1$s turned on slow mode (%2$d seconds). %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%3$s in %4$s.%5$s + %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%4$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%4$s - for %1$s - by %1$s - : \"%1$s\" - : %1$s - saying: \"%1$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 - (%1$d time) - (%1$d times) - - - (%1$d minute) - (%1$d minutes) - - - (%1$d second) - (%1$d seconds) + \u0020(%1$d time) + \u0020(%1$d times) Backspace diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b44021a8f..d9b66c624 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -470,11 +470,17 @@ - You were timed out%1$s%2$s%3$s.%4$s - %1$s timed out %2$s%3$s.%4$s - %1$s has been timed out%2$s.%3$s - You were banned%1$s%2$s. - %1$s banned %2$s%3$s. + 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. @@ -482,44 +488,40 @@ %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%3$s + %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%3$s. - A message from %1$s was deleted%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.%2$s + %1$s turned on followers-only mode. + %1$s turned on followers-only mode (%2$d minutes). %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.%2$s + %1$s turned on slow mode. + %1$s turned on slow mode (%2$d seconds). %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%3$s in %4$s.%5$s + %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%4$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%4$s - for %1$s - by %1$s - : \"%1$s\" - : %1$s - saying: \"%1$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 - (%1$d time) - (%1$d times) - - - (%1$d minute) - (%1$d minutes) - - - (%1$d second) - (%1$d seconds) + \u0020(%1$d time) + \u0020(%1$d times) diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 98053514d..a8a087ca9 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -478,68 +478,60 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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%1$s%2$s%3$s.%4$s - %1$s expulsó temporalmente a %2$s%3$s.%4$s - %1$s ha sido expulsado temporalmente%2$s.%3$s - - Fuiste baneado%1$s%2$s. - %1$s baneó a %2$s%3$s. + + 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%3$s + %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%3$s. - Un mensaje de %1$s fue eliminado%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.%2$s + %1$s activó el modo followers-only. + %1$s activó el modo followers-only (%2$d minutos). %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.%2$s + %1$s activó el modo slow. + %1$s activó el modo slow (%2$d segundos). %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%3$s en %4$s.%5$s + %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%4$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%4$s - - por %1$s - por %1$s - : \"%1$s\" - : %1$s - diciendo: \"%1$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 - (%1$d vez) - (%1$d veces) - (%1$d veces) - - - (%1$d minuto) - (%1$d minutos) - (%1$d minutos) - - - (%1$d segundo) - (%1$d segundos) - (%1$d segundos) + \u0020(%1$d vez) + \u0020(%1$d veces) + \u0020(%1$d veces) diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 26ed5b856..34befa8f3 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -313,12 +313,17 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %1$s poisti %2$s sallittuna terminä AutoModista. - - Sinut asetettiin jäähylle%1$s%2$s%3$s.%4$s - %1$s asetti jäähylle käyttäjän %2$s%3$s.%4$s - %1$s on asetettu jäähylle%2$s.%3$s - Sinut estettiin%1$s%2$s. - %1$s esti käyttäjän %2$s%3$s. + 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. @@ -326,44 +331,40 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %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%3$s + %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%3$s. - Viesti käyttäjältä %1$s poistettiin%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 tyhjentsi 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.%2$s + %1$s otti käyttöön vain seuraajat -tilan. + %1$s otti käyttöön vain seuraajat -tilan (%2$d minuuttia). %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.%2$s + %1$s otti käyttöön hitaan tilan. + %1$s otti käyttöön hitaan tilan (%2$d sekuntia). %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 jäähylle käyttäjän %2$s%3$s kanavalla %4$s.%5$s + %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%4$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%4$s - %1$s ajaksi - käyttäjän %1$s toimesta - : \"%1$s\" - : %1$s - sanoen: \"%1$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 - (%1$d kerta) - (%1$d kertaa) - - - (%1$d minuutti) - (%1$d minuuttia) - - - (%1$d sekunti) - (%1$d sekuntia) + \u0020(%1$d kerta) + \u0020(%1$d kertaa) diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 0734248a1..b4c1edc47 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -462,68 +462,60 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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%1$s%2$s%3$s.%4$s - %1$s a exclu temporairement %2$s%3$s.%4$s - %1$s a été exclu temporairement%2$s.%3$s - - Vous avez été banni%1$s%2$s. - %1$s a banni %2$s%3$s. + + 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%3$s + %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%3$s. - Un message de %1$s a été supprimé%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.%2$s + %1$s a activé le mode followers-only. + %1$s a activé le mode followers-only (%2$d minutes). %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.%2$s + %1$s a activé le mode slow. + %1$s a activé le mode slow (%2$d secondes). %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%3$s dans %4$s.%5$s + %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%4$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%4$s - - pour %1$s - par %1$s - : \"%1$s\" - : %1$s - disant : \"%1$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 - (%1$d fois) - (%1$d fois) - (%1$d fois) - - - (%1$d minute) - (%1$d minutes) - (%1$d minutes) - - - (%1$d seconde) - (%1$d secondes) - (%1$d secondes) + \u0020(%1$d fois) + \u0020(%1$d fois) + \u0020(%1$d fois) diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 642fddaec..5a0478ca9 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -454,12 +454,17 @@ %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%2$s%3$s.%4$s - %1$s ideiglenesen kitiltotta %2$s felhasználót%3$s.%4$s - %1$s ideiglenesen ki lett tiltva%2$s.%3$s - Ki lettél tiltva%1$s%2$s. - %1$s kitiltotta %2$s felhasználót%3$s. + 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. @@ -467,44 +472,40 @@ %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%3$s + %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%3$s. - %1$s üzenete törölve lett%2$s. + %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.%2$s + %1$s bekapcsolta a csak követők módot. + %1$s bekapcsolta a csak követők módot (%2$d perc). %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.%2$s + %1$s bekapcsolta a lassú módot. + %1$s bekapcsolta a lassú módot (%2$d másodperc). %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 a(z) %4$s csatornán.%5$s + %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%4$s. + %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%4$s - %1$s időtartamra - %1$s által - : \"%1$s\" - : %1$s - mondván: \"%1$s\" + %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 - (%1$d alkalommal) - (%1$d alkalommal) - - - (%1$d perc) - (%1$d perc) - - - (%1$d másodperc) - (%1$d másodperc) + \u0020(%1$d alkalommal) + \u0020(%1$d alkalommal) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d4bec0f17..45f1cf5e6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -445,68 +445,60 @@ %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%1$s%2$s%3$s.%4$s - %1$s ha espulso temporaneamente %2$s%3$s.%4$s - %1$s è stato espulso temporaneamente%2$s.%3$s - - Sei stato bannato%1$s%2$s. - %1$s ha bannato %2$s%3$s. + + 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%3$s + %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%3$s. - Un messaggio di %1$s è stato eliminato%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.%2$s + %1$s ha attivato la modalità followers-only. + %1$s ha attivato la modalità followers-only (%2$d minuti). %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.%2$s + %1$s ha attivato la modalità slow. + %1$s ha attivato la modalità slow (%2$d secondi). %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%3$s in %4$s.%5$s + %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%4$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%4$s - - per %1$s - da %1$s - : \"%1$s\" - : %1$s - dicendo: \"%1$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 - (%1$d volta) - (%1$d volte) - (%1$d volte) - - - (%1$d minuto) - (%1$d minuti) - (%1$d minuti) - - - (%1$d secondo) - (%1$d secondi) - (%1$d secondi) + \u0020(%1$d volta) + \u0020(%1$d volte) + \u0020(%1$d volte) diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 994df8daf..f1b634c6f 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -442,11 +442,17 @@ - あなたは%1$s%2$s%3$sタイムアウトされました。%4$s - %1$sが%2$sを%3$sタイムアウトしました。%4$s - %1$sは%2$sタイムアウトされました。%3$s - あなたは%1$s%2$sBANされました。 - %1$sが%2$sを%3$sBANしました。 + あなたは%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を解除しました。 @@ -454,41 +460,39 @@ %1$sが%2$sのモデレーターを解除しました。 %1$sが%2$sをこのチャンネルのVIPに追加しました。 %1$sが%2$sをこのチャンネルのVIPから削除しました。 - %1$sが%2$sに警告しました%3$s + %1$sが%2$sに警告しました。 + %1$sが%2$sに警告しました: %3$s %1$sが%2$sへのレイドを開始しました。 %1$sが%2$sへのレイドをキャンセルしました。 - %1$sが%2$sのメッセージを削除しました%3$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 modeをオンにしました。 - %1$sがemote-only modeをオフにしました。 - %1$sがfollowers-only modeをオンにしました。%2$s - %1$sがfollowers-only modeをオフにしました。 - %1$sがunique-chat modeをオンにしました。 - %1$sがunique-chat modeをオフにしました。 - %1$sがスローモードをオンにしました。%2$s + %1$sがエモート限定モードをオンにしました。 + %1$sがエモート限定モードをオフにしました。 + %1$sがフォロワー限定モードをオンにしました。 + %1$sがフォロワー限定モードをオンにしました (%2$d分)。 + %1$sがフォロワー限定モードをオフにしました。 + %1$sがユニークチャットモードをオンにしました。 + %1$sがユニークチャットモードをオフにしました。 + %1$sがスローモードをオンにしました。 + %1$sがスローモードをオンにしました (%2$d秒)。 %1$sがスローモードをオフにしました。 - %1$sがsubscribers-only modeをオンにしました。 - %1$sがsubscribers-only modeをオフにしました。 - %1$sが%4$sで%2$sを%3$sタイムアウトしました。%5$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しました%4$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のメッセージを削除しました%4$s - %1$s間 - %1$sにより - : \"%1$s\" - : %1$s - 内容: \"%1$s\" + %1$sが%3$sで%2$sのメッセージを削除しました。 + %1$sが%3$sで%2$sのメッセージを削除しました 内容: \"%4$s\"。 + %1$s%2$s - (%1$d回) - - - (%1$d分) - - - (%1$d秒) + \u0020(%1$d回) diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 41a76efa1..9f36df714 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -482,11 +482,17 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości - Zostałeś/aś wyciszony/a%1$s%2$s%3$s.%4$s - %1$s wyciszył/a %2$s%3$s.%4$s - %1$s został/a wyciszony/a%2$s.%3$s - Zostałeś/aś zbanowany/a%1$s%2$s. - %1$s zbanował/a %2$s%3$s. + 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. @@ -494,50 +500,42 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %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%3$s + %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%3$s. - Wiadomość od %1$s została usunięta%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.%2$s + %1$s włączył/a tryb tylko dla obserwujących. + %1$s włączył/a tryb tylko dla obserwujących (%2$d minut). %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.%2$s + %1$s włączył/a tryb powolny. + %1$s włączył/a tryb powolny (%2$d sekund). %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%3$s w %4$s.%5$s + %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%4$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%4$s - na %1$s - przez %1$s - : \"%1$s\" - : %1$s - mówiąc: \"%1$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 - (%1$d raz) - (%1$d razy) - (%1$d razy) - (%1$d razy) - - - (%1$d minuta) - (%1$d minuty) - (%1$d minut) - (%1$d minut) - - - (%1$d sekunda) - (%1$d sekundy) - (%1$d sekund) - (%1$d sekund) + \u0020(%1$d raz) + \u0020(%1$d razy) + \u0020(%1$d razy) + \u0020(%1$d razy) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 538aea76a..81b855928 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -458,12 +458,17 @@ %1$s removeu %2$s como termo permitido do AutoMod. - - Você foi suspenso%1$s%2$s%3$s.%4$s - %1$s suspendeu %2$s%3$s.%4$s - %1$s foi suspenso%2$s.%3$s - Você foi banido%1$s%2$s. - %1$s baniu %2$s%3$s. + 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. @@ -471,47 +476,41 @@ %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%3$s + %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%3$s. - Uma mensagem de %1$s foi excluída%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.%2$s + %1$s ativou o modo somente seguidores. + %1$s ativou o modo somente seguidores (%2$d minutos). %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.%2$s + %1$s ativou o modo lento. + %1$s ativou o modo lento (%2$d segundos). %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%3$s em %4$s.%5$s + %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%4$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%4$s - por %1$s - por %1$s - : \"%1$s\" - : %1$s - dizendo: \"%1$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 - (%1$d vez) - (%1$d vezes) - (%1$d vezes) - - - (%1$d minuto) - (%1$d minutos) - (%1$d minutos) - - - (%1$d segundo) - (%1$d segundos) - (%1$d segundos) + \u0020(%1$d vez) + \u0020(%1$d vezes) + \u0020(%1$d vezes) diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d3c668ca9..d7290e30b 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -448,12 +448,17 @@ %1$s removeu %2$s como termo permitido do AutoMod. - - Foste suspenso%1$s%2$s%3$s.%4$s - %1$s suspendeu %2$s%3$s.%4$s - %1$s foi suspenso%2$s.%3$s - Foste banido%1$s%2$s. - %1$s baniu %2$s%3$s. + 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. @@ -461,47 +466,41 @@ %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%3$s + %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%3$s. - Uma mensagem de %1$s foi eliminada%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.%2$s + %1$s ativou o modo apenas seguidores. + %1$s ativou o modo apenas seguidores (%2$d minutos). %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.%2$s + %1$s ativou o modo lento. + %1$s ativou o modo lento (%2$d segundos). %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%3$s em %4$s.%5$s + %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%4$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%4$s - por %1$s - por %1$s - : \"%1$s\" - : %1$s - a dizer: \"%1$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 - (%1$d vez) - (%1$d vezes) - (%1$d vezes) - - - (%1$d minuto) - (%1$d minutos) - (%1$d minutos) - - - (%1$d segundo) - (%1$d segundos) - (%1$d segundos) + \u0020(%1$d vez) + \u0020(%1$d vezes) + \u0020(%1$d vezes) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 467c88f0d..d8197d7b2 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -468,11 +468,17 @@ - Вы были заглушены%1$s%2$s%3$s.%4$s - %1$s заглушил %2$s%3$s.%4$s - %1$s был заглушён%2$s.%3$s - Вы были забанены%1$s%2$s. - %1$s забанил %2$s%3$s. + Вы были заглушены на %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. @@ -480,50 +486,42 @@ %1$s снял модератора с %2$s. %1$s добавил %2$s как VIP этого канала. %1$s удалил %2$s как VIP этого канала. - %1$s предупредил %2$s%3$s + %1$s предупредил %2$s. + %1$s предупредил %2$s: %3$s %1$s начал рейд на %2$s. %1$s отменил рейд на %2$s. - %1$s удалил сообщение от %2$s%3$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 включил режим только для подписчиков канала.%2$s + %1$s включил режим только для подписчиков канала. + %1$s включил режим только для подписчиков канала (%2$d минут). %1$s выключил режим только для подписчиков канала. %1$s включил режим уникального чата. %1$s выключил режим уникального чата. - %1$s включил медленный режим.%2$s + %1$s включил медленный режим. + %1$s включил медленный режим (%2$d секунд). %1$s выключил медленный режим. %1$s включил режим только для подписчиков. %1$s выключил режим только для подписчиков. - %1$s заглушил %2$s%3$s в %4$s.%5$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%4$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%4$s - на %1$s - от %1$s - : \"%1$s\" - : %1$s - с текстом: \"%1$s\" + %1$s удалил сообщение от %2$s в %3$s. + %1$s удалил сообщение от %2$s в %3$s с текстом: \"%4$s\". + %1$s%2$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 секунд) + \u0020(%1$d раз) + \u0020(%1$d раза) + \u0020(%1$d раз) + \u0020(%1$d раз) diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index f5632fffc..fcff7ad27 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -256,11 +256,17 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/ - Добили сте тајмаут%1$s%2$s%3$s.%4$s - %1$s је дао тајмаут кориснику %2$s%3$s.%4$s - %1$s је добио тајмаут%2$s.%3$s - Бановани сте%1$s%2$s. - %1$s је бановао %2$s%3$s. + Добили сте тајмаут на %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. @@ -268,47 +274,41 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%1$s је уклонио %2$s са модератора. %1$s је додао %2$s као VIP овог канала. %1$s је уклонио %2$s као VIP овог канала. - %1$s је упозорио %2$s%3$s + %1$s је упозорио %2$s. + %1$s је упозорио %2$s: %3$s %1$s је покренуо рејд на %2$s. %1$s је отказао рејд на %2$s. - %1$s је обрисао поруку од %2$s%3$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 режим.%2$s + %1$s је укључио followers-only режим. + %1$s је укључио followers-only режим (%2$d минута). %1$s је искључио followers-only режим. %1$s је укључио unique-chat режим. %1$s је искључио unique-chat режим. - %1$s је укључио спори режим.%2$s + %1$s је укључио спори режим. + %1$s је укључио спори режим (%2$d секунди). %1$s је искључио спори режим. %1$s је укључио subscribers-only режим. %1$s је искључио subscribers-only режим. - %1$s је дао тајмаут кориснику %2$s%3$s у %4$s.%5$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%4$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%4$s - на %1$s - од стране %1$s - : \"%1$s\" - : %1$s - са садржајем: \"%1$s\" + %1$s је обрисао поруку од %2$s у %3$s. + %1$s је обрисао поруку од %2$s у %3$s са садржајем: \"%4$s\". + %1$s%2$s - (%1$d пут) - (%1$d пута) - (%1$d пута) - - - (%1$d минут) - (%1$d минута) - (%1$d минута) - - - (%1$d секунда) - (%1$d секунде) - (%1$d секунди) + \u0020(%1$d пут) + \u0020(%1$d пута) + \u0020(%1$d пута) diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e26fcb524..1eac72fb1 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -475,12 +475,17 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %1$s, AutoMod üzerinden %2$s terimini izin verilen terim olarak kaldırdı. - - Zaman aşımına uğratıldınız%1$s%2$s%3$s.%4$s - %1$s, %2$s kullanıcısını zaman aşımına uğrattı%3$s.%4$s - %1$s zaman aşımına uğratıldı%2$s.%3$s - Banlandınız%1$s%2$s. - %1$s, %2$s kullanıcısını banladı%3$s. + %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ı. @@ -488,44 +493,40 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %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ı%3$s + %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%3$s. - %1$s kullanıcısının bir mesajı silindi%2$s. + %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ı.%2$s + %1$s yalnızca takipçiler modunu açtı. + %1$s yalnızca takipçiler modunu açtı (%2$d dakika). %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ı.%2$s + %1$s yavaş modu açtı. + %1$s yavaş modu açtı (%2$d saniye). %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 %4$s kanalında zaman aşımına uğrattı.%5$s + %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ı%4$s. + %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%4$s - %1$s süreliğine - %1$s tarafından - : \"%1$s\" - : %1$s - şunu diyerek: \"%1$s\" + %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 - (%1$d kez) - (%1$d kez) - - - (%1$d dakika) - (%1$d dakika) - - - (%1$d saniye) - (%1$d saniye) + \u0020(%1$d kez) + \u0020(%1$d kez) diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index e8c2ba9a2..63f58da6b 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -465,11 +465,17 @@ - Вас було заглушено%1$s%2$s%3$s.%4$s - %1$s заглушив %2$s%3$s.%4$s - %1$s було заглушено%2$s.%3$s - Вас було забанено%1$s%2$s. - %1$s забанив %2$s%3$s. + Вас було заглушено на %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. @@ -477,50 +483,42 @@ %1$s зняв модератора з %2$s. %1$s додав %2$s як VIP цього каналу. %1$s видалив %2$s як VIP цього каналу. - %1$s попередив %2$s%3$s + %1$s попередив %2$s. + %1$s попередив %2$s: %3$s %1$s розпочав рейд на %2$s. %1$s скасував рейд на %2$s. - %1$s видалив повідомлення від %2$s%3$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 увімкнув режим лише для підписників каналу.%2$s + %1$s увімкнув режим лише для підписників каналу. + %1$s увімкнув режим лише для підписників каналу (%2$d хвилин). %1$s вимкнув режим лише для підписників каналу. %1$s увімкнув режим унікального чату. %1$s вимкнув режим унікального чату. - %1$s увімкнув повільний режим.%2$s + %1$s увімкнув повільний режим. + %1$s увімкнув повільний режим (%2$d секунд). %1$s вимкнув повільний режим. %1$s увімкнув режим лише для підписників. %1$s вимкнув режим лише для підписників. - %1$s заглушив %2$s%3$s у %4$s.%5$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%4$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%4$s - на %1$s - від %1$s - : \"%1$s\" - : %1$s - з текстом: \"%1$s\" + %1$s видалив повідомлення від %2$s у %3$s. + %1$s видалив повідомлення від %2$s у %3$s з текстом: \"%4$s\". + %1$s%2$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 секунд) + \u0020(%1$d раз) + \u0020(%1$d рази) + \u0020(%1$d разів) + \u0020(%1$d разів) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 740cfbeee..d53b12a07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,14 +114,24 @@ %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%1$s%2$s%3$s.%4$s - %1$s timed out %2$s%3$s.%4$s - %1$s has been timed out%2$s.%3$s - - You were banned%1$s%2$s. - %1$s banned %2$s%3$s. + + + 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. @@ -130,49 +140,46 @@ %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%3$s + %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%3$s. - A message from %1$s was deleted%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.%2$s + %1$s turned on followers-only mode. + %1$s turned on followers-only mode (%2$d minutes). %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.%2$s + %1$s turned on slow mode. + %1$s turned on slow mode (%2$d seconds). %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%3$s in %4$s.%5$s + %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%4$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%4$s - - for %1$s - by %1$s - : \"%1$s\" - : %1$s - saying: \"%1$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 + - (%1$d time) - (%1$d times) - - - (%1$d minute) - (%1$d minutes) - - - (%1$d second) - (%1$d seconds) + \u0020(%1$d time) + \u0020(%1$d times) < Message deleted > From 723143042f5c7fdfe8f361c6b561f6ba2467373e Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 15:59:25 +0100 Subject: [PATCH 064/349] feat(compose): Color usernames in moderation messages --- .../chat/compose/ChatMessageMapper.kt | 6 +- .../chat/compose/ChatMessageUiState.kt | 4 + .../chat/compose/messages/SystemMessages.kt | 108 ++++++++++++++++-- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 97fcdb41b..bf73fbd1a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -258,7 +258,11 @@ class ChatMessageMapper( lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, - message = getSystemMessage(preferenceStore.userName, chatSettings.showTimedOutMessages) + 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, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 7de2f7423..8c20c31b0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -118,6 +118,10 @@ sealed interface ChatMessageUiState { override val enableRipple: Boolean = false, override val isHighlighted: Boolean = false, val message: TextResource, + val creatorName: String? = null, + val targetName: String? = null, + val creatorColor: Int = Message.DEFAULT_COLOR, + val targetColor: Int = Message.DEFAULT_COLOR, ) : ChatMessageUiState /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index 43794dd14..f7d39a0ef 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -29,6 +29,7 @@ import androidx.core.net.toUri import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.messages.common.SimpleMessageContainer +import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberNormalizedColor import com.flxrs.dankchat.chat.compose.resolve @@ -202,21 +203,108 @@ fun DateSeparatorComposable( } /** - * Renders a moderation message (timeouts, bans, deletions) + * Renders a moderation message (timeouts, bans, deletions) with colored usernames. */ +@Suppress("DEPRECATION") @Composable fun ModerationMessageComposable( message: ChatMessageUiState.ModerationMessageUi, 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, - ) + val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val textColor = rememberAdaptiveTextColor(bgColor) + val timestampColor = MaterialTheme.colorScheme.onSurface + val creatorColor = rememberNormalizedColor(message.creatorColor, bgColor) + val targetColor = rememberNormalizedColor(message.targetColor, bgColor) + val textSize = fontSize.sp + val resolvedMessage = message.message.resolve() + val context = LocalContext.current + val linkColor = MaterialTheme.colorScheme.primary + + val annotatedString = remember( + message, resolvedMessage, textColor, creatorColor, targetColor, linkColor, timestampColor, textSize + ) { + 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) + } + append(" ") + } + + // Build list of name ranges to color + val nameRanges = buildList { + var searchFrom = 0 + message.creatorName?.let { name -> + val idx = resolvedMessage.indexOf(name, startIndex = searchFrom, ignoreCase = true) + if (idx >= 0) { + add(Triple(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(Triple(idx, name.length, targetColor)) + } + }.sortedBy { it.first } + + // Render message with colored name segments + var cursor = 0 + for ((start, length, color) in nameRanges) { + if (start < cursor) continue // skip overlapping + if (start > cursor) { + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(resolvedMessage.substring(cursor, start), linkColor) + } + } + withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { + append(resolvedMessage.substring(start, start + length)) + } + cursor = start + length + } + if (cursor < resolvedMessage.length) { + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(resolvedMessage.substring(cursor), linkColor) + } + } + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(bgColor) + .padding(horizontal = 2.dp, vertical = 2.dp) + ) { + ClickableText( + text = annotatedString, + style = TextStyle(fontSize = textSize), + modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + annotatedString.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, annotation.item.toUri()) + } catch (e: Exception) { + Log.e("ModerationMessage", "Error launching URL", e) + } + } + } + ) + } } From 898bfc64d2e2d837cb2ff563e5e4c5a17a3b52fb Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 16:43:37 +0100 Subject: [PATCH 065/349] feat(compose): Style moderation messages with dimmed template text, remove quotes and trailing periods --- .../chat/compose/ChatMessageMapper.kt | 7 ++ .../chat/compose/ChatMessageUiState.kt | 2 + .../chat/compose/messages/SystemMessages.kt | 76 +++++++++------ app/src/main/res/values-be-rBY/strings.xml | 94 +++++++++---------- app/src/main/res/values-ca/strings.xml | 94 +++++++++---------- app/src/main/res/values-cs/strings.xml | 94 +++++++++---------- app/src/main/res/values-de-rDE/strings.xml | 94 +++++++++---------- app/src/main/res/values-en-rAU/strings.xml | 94 +++++++++---------- app/src/main/res/values-en-rGB/strings.xml | 94 +++++++++---------- app/src/main/res/values-en/strings.xml | 94 +++++++++---------- app/src/main/res/values-es-rES/strings.xml | 94 +++++++++---------- app/src/main/res/values-fi-rFI/strings.xml | 94 +++++++++---------- app/src/main/res/values-fr-rFR/strings.xml | 94 +++++++++---------- app/src/main/res/values-hu-rHU/strings.xml | 94 +++++++++---------- app/src/main/res/values-it/strings.xml | 94 +++++++++---------- app/src/main/res/values-ja-rJP/strings.xml | 94 +++++++++---------- app/src/main/res/values-pl-rPL/strings.xml | 94 +++++++++---------- app/src/main/res/values-pt-rBR/strings.xml | 94 +++++++++---------- app/src/main/res/values-pt-rPT/strings.xml | 94 +++++++++---------- app/src/main/res/values-ru-rRU/strings.xml | 94 +++++++++---------- app/src/main/res/values-sr/strings.xml | 94 +++++++++---------- app/src/main/res/values-tr-rTR/strings.xml | 94 +++++++++---------- app/src/main/res/values-uk-rUA/strings.xml | 94 +++++++++---------- app/src/main/res/values/strings.xml | 94 +++++++++---------- 24 files changed, 1043 insertions(+), 1016 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index bf73fbd1a..48608708c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -251,6 +251,12 @@ class ChatMessageMapper( DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) } else "" + val arguments = buildList { + duration?.let(::add) + reason?.takeIf { it.isNotBlank() }?.let(::add) + sourceBroadcasterDisplay?.toString()?.let(::add) + }.toImmutableList() + return ChatMessageUiState.ModerationMessageUi( id = id, tag = tag, @@ -263,6 +269,7 @@ class ChatMessageMapper( 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, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 8c20c31b0..0cf2c475f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -10,6 +10,7 @@ import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.MessageThreadHeader import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * UI state for rendering chat messages in Compose. @@ -122,6 +123,7 @@ sealed interface ChatMessageUiState { val targetName: String? = null, val creatorColor: Int = Message.DEFAULT_COLOR, val targetColor: Int = Message.DEFAULT_COLOR, + val arguments: ImmutableList = persistentListOf(), ) : ChatMessageUiState /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index f7d39a0ef..053af0301 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -3,6 +3,7 @@ package com.flxrs.dankchat.chat.compose.messages import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.layout.Box @@ -202,6 +203,8 @@ fun DateSeparatorComposable( ) } +private data class StyledRange(val start: Int, val length: Int, val color: Color, val bold: Boolean) + /** * Renders a moderation message (timeouts, bans, deletions) with colored usernames. */ @@ -214,7 +217,7 @@ fun ModerationMessageComposable( ) { val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) - val timestampColor = MaterialTheme.colorScheme.onSurface + 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 @@ -222,9 +225,36 @@ fun ModerationMessageComposable( val context = LocalContext.current val linkColor = MaterialTheme.colorScheme.primary + val dimmedTextColor = textColor.copy(alpha = 0.7f) + val annotatedString = remember( - message, resolvedMessage, textColor, creatorColor, targetColor, linkColor, timestampColor, textSize + message, resolvedMessage, 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, bold = true)) + 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, bold = true)) + } + } + for (arg in message.arguments) { + 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, bold = false)) + } + } + }.sortedBy { it.start } + buildAnnotatedString { // Timestamp if (message.timestamp.isNotEmpty()) { @@ -242,39 +272,27 @@ fun ModerationMessageComposable( append(" ") } - // Build list of name ranges to color - val nameRanges = buildList { - var searchFrom = 0 - message.creatorName?.let { name -> - val idx = resolvedMessage.indexOf(name, startIndex = searchFrom, ignoreCase = true) - if (idx >= 0) { - add(Triple(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(Triple(idx, name.length, targetColor)) - } - }.sortedBy { it.first } - - // Render message with colored name segments + // Render message: highlighted ranges at full opacity, template text dimmed var cursor = 0 - for ((start, length, color) in nameRanges) { - if (start < cursor) continue // skip overlapping - if (start > cursor) { - withStyle(SpanStyle(color = textColor)) { - appendWithLinks(resolvedMessage.substring(cursor, start), linkColor) + 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 = color, fontWeight = FontWeight.Bold)) { - append(resolvedMessage.substring(start, start + length)) + val style = when { + range.bold -> SpanStyle(color = range.color, fontWeight = FontWeight.Bold) + else -> SpanStyle(color = range.color) + } + withStyle(style) { + append(resolvedMessage.substring(range.start, range.start + range.length)) } - cursor = start + length + cursor = range.start + range.length } if (cursor < resolvedMessage.length) { - withStyle(SpanStyle(color = textColor)) { - appendWithLinks(resolvedMessage.substring(cursor), linkColor) + withStyle(SpanStyle(color = dimmedTextColor)) { + append(resolvedMessage.substring(cursor)) } } } diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 472693129..77ff7b8b7 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -463,54 +463,54 @@ - Вас было заглушана на %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 + Вас было заглушана на %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$d хвілін). - %1$s выключыў рэжым толькі для падпісчыкаў канала. - %1$s уключыў рэжым унікальнага чату. - %1$s выключыў рэжым унікальнага чату. - %1$s уключыў павольны рэжым. - %1$s уключыў павольны рэжым (%2$d секунд). - %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 + %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$d хвілін) + %1$s выключыў рэжым толькі для падпісчыкаў канала + %1$s уключыў рэжым унікальнага чату + %1$s выключыў рэжым унікальнага чату + %1$s уключыў павольны рэжым + %1$s уключыў павольны рэжым (%2$d секунд) + %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 раз) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index e02875aab..979438998 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -350,54 +350,54 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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. + 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$d minuts). - %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$d segons). - %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 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$d minuts) + %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$d segons) + %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) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ec73ffbeb..6cf89b7e6 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -464,54 +464,54 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade - 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. + 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$d minut). - %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$d sekund). - %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 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$d minut) + %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$d sekund) + %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) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 0c2110e8b..6a841e830 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -476,54 +476,54 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %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. + 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$d Minuten). - %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$d Sekunden). - %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 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$d Minuten) + %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$d Sekunden) + %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) diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 8d7420e35..edd23cb7a 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -287,54 +287,54 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/ - 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. + 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$d minutes). - %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$d seconds). - %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 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$d minutes) + %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$d seconds) + %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) diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 8ff54464e..5958e5149 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -288,54 +288,54 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/ - 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. + 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$d minutes). - %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$d seconds). - %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 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$d minutes) + %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$d seconds) + %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) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d9b66c624..d5194b64a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -470,54 +470,54 @@ - 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. + 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$d minutes). - %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$d seconds). - %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 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$d minutes) + %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$d seconds) + %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) diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index a8a087ca9..6981403e4 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -479,54 +479,54 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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. + 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$d minutos). - %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$d segundos). - %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 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$d minutos) + %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$d segundos) + %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) diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 34befa8f3..6595e7eb3 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -313,54 +313,54 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %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. + 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$d minuuttia). - %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$d sekuntia). - %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 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$d minuuttia) + %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$d sekuntia) + %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) diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b4c1edc47..c49ff33ae 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -463,54 +463,54 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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. + 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$d minutes). - %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$d secondes). - %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 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$d minutes) + %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$d secondes) + %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) diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 5a0478ca9..1f3b9b4f2 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -454,54 +454,54 @@ %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. + 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$d perc). - %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$d másodperc). - %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 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$d perc) + %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$d másodperc) + %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) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 45f1cf5e6..d5ca5230b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -446,54 +446,54 @@ %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. + 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$d minuti). - %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$d secondi). - %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 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$d minuti) + %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$d secondi) + %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) diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index f1b634c6f..bf887ad30 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -442,54 +442,54 @@ - あなたは%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により%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$d分)。 - %1$sがフォロワー限定モードをオフにしました。 - %1$sがユニークチャットモードをオンにしました。 - %1$sがユニークチャットモードをオフにしました。 - %1$sがスローモードをオンにしました。 - %1$sがスローモードをオンにしました (%2$d秒)。 - %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へのレイドを開始しました + %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$d分) + %1$sがフォロワー限定モードをオフにしました + %1$sがユニークチャットモードをオンにしました + %1$sがユニークチャットモードをオフにしました + %1$sがスローモードをオンにしました + %1$sがスローモードをオンにしました (%2$d秒) + %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回) diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 9f36df714..efa5531be 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -482,54 +482,54 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości - 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. + 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$d minut). - %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$d sekund). - %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 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$d minut) + %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$d sekund) + %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) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 81b855928..06cf0289b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -458,54 +458,54 @@ %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. + 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$d minutos). - %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$d segundos). - %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 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$d minutos) + %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$d segundos) + %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) diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d7290e30b..b91ed0b2d 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -448,54 +448,54 @@ %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. + 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$d minutos). - %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$d segundos). - %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 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$d minutos) + %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$d segundos) + %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) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index d8197d7b2..c88384eb4 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -468,54 +468,54 @@ - Вы были заглушены на %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 + Вы были заглушены на %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$d минут). - %1$s выключил режим только для подписчиков канала. - %1$s включил режим уникального чата. - %1$s выключил режим уникального чата. - %1$s включил медленный режим. - %1$s включил медленный режим (%2$d секунд). - %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 + %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$d минут) + %1$s выключил режим только для подписчиков канала + %1$s включил режим уникального чата + %1$s выключил режим уникального чата + %1$s включил медленный режим + %1$s включил медленный режим (%2$d секунд) + %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 раз) diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index fcff7ad27..04a25ed2b 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -256,54 +256,54 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/ - Добили сте тајмаут на %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 + Добили сте тајмаут на %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$d минута). - %1$s је искључио followers-only режим. - %1$s је укључио unique-chat режим. - %1$s је искључио unique-chat режим. - %1$s је укључио спори режим. - %1$s је укључио спори режим (%2$d секунди). - %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 + %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$d минута) + %1$s је искључио followers-only режим + %1$s је укључио unique-chat режим + %1$s је искључио unique-chat режим + %1$s је укључио спори режим + %1$s је укључио спори режим (%2$d секунди) + %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 пут) diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 1eac72fb1..9e052c9a3 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -475,54 +475,54 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %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 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$d dakika). - %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$d saniye). - %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 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$d dakika) + %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$d saniye) + %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) diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 63f58da6b..1b2191aee 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -465,54 +465,54 @@ - Вас було заглушено на %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 + Вас було заглушено на %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$d хвилин). - %1$s вимкнув режим лише для підписників каналу. - %1$s увімкнув режим унікального чату. - %1$s вимкнув режим унікального чату. - %1$s увімкнув повільний режим. - %1$s увімкнув повільний режим (%2$d секунд). - %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 + %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$d хвилин) + %1$s вимкнув режим лише для підписників каналу + %1$s увімкнув режим унікального чату + %1$s вимкнув режим унікального чату + %1$s увімкнув повільний режим + %1$s увімкнув повільний режим (%2$d секунд) + %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 раз) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d53b12a07..dd282add5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,64 +116,64 @@ - You were timed out for %1$s. + 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\". + 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 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. + %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\". + 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 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 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 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 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 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$d minutes). - %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$d seconds). - %1$s turned off slow mode. - %1$s turned on subscribers-only mode. - %1$s turned off subscribers-only mode. + %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$d minutes) + %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$d seconds) + %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 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 From 276908ccdffcc963500931369d24334b086907f1 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 17:56:42 +0100 Subject: [PATCH 066/349] feat(compose): Add jump-to-message for reply threads, remove inline jump buttons from sheets --- .../flxrs/dankchat/chat/mention/compose/MentionComposable.kt | 2 -- .../flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt | 5 ++--- .../com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt | 2 -- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index e911bb049..6788b04a6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -34,7 +34,6 @@ fun MentionComposable( onEmoteClick: (List) -> Unit, modifier: Modifier = Modifier, onWhisperReply: ((userName: UserName) -> Unit)? = null, - onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, containerColor: Color, contentPadding: PaddingValues = PaddingValues(), ) { @@ -59,7 +58,6 @@ fun MentionComposable( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, onWhisperReply = if (isWhisperTab) onWhisperReply else null, - onJumpToMessage = if (!isWhisperTab) onJumpToMessage else null, contentPadding = contentPadding, containerColor = containerColor, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index 4ea20c284..b3c64d859 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -121,7 +121,6 @@ fun FullScreenSheetOverlay( }, onEmoteClick = onEmoteClick, onWhisperReply = onWhisperReply, - onJumpToMessage = onJumpToMessage, bottomContentPadding = bottomContentPadding, ) } @@ -148,7 +147,6 @@ fun FullScreenSheetOverlay( }, onEmoteClick = onEmoteClick, onWhisperReply = onWhisperReply, - onJumpToMessage = onJumpToMessage, bottomContentPadding = bottomContentPadding, ) } @@ -167,7 +165,8 @@ fun FullScreenSheetOverlay( fullMessage = fullMessage, canModerate = isLoggedIn, canReply = isLoggedIn, - canCopy = true + canCopy = true, + canJump = true, ) ) }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index c6bdea395..4c06c8266 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -56,7 +56,6 @@ fun MentionSheet( onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onEmoteClick: (List) -> Unit, onWhisperReply: ((userName: UserName) -> Unit)? = null, - onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, bottomContentPadding: Dp = 0.dp, ) { val scope = rememberCoroutineScope() @@ -115,7 +114,6 @@ fun MentionSheet( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, onWhisperReply = if (page == 1) onWhisperReply else null, - onJumpToMessage = if (page == 0) onJumpToMessage else null, containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), ) From 808f64c2c37b5c9307096fe5a38b66e2919dfb28 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 18:13:32 +0100 Subject: [PATCH 067/349] fix(compose): Reduce login snackbar duration to 2 seconds --- .../flxrs/dankchat/main/compose/MainScreenEventHandler.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index b55f254da..a3dd11d5d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -19,6 +19,7 @@ import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.main.MainActivity import com.flxrs.dankchat.main.MainEvent import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -81,6 +82,10 @@ fun MainScreenEventHandler( 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, From c4215f0c681a9818469708a099999d23d402da26 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 16 Mar 2026 18:29:50 +0100 Subject: [PATCH 068/349] feat: Increase recent emote usage limit from 30 to 60 --- .../com/flxrs/dankchat/data/database/dao/EmoteUsageDao.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..9c777d7d9 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 @@ -22,6 +22,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 From a56bce7becea308d0d53482a394041d3bf969d44 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 17 Mar 2026 12:55:30 +0100 Subject: [PATCH 069/349] fix: Remove force unwraps, unsafe casts, null-safety issues, and dead legacy code --- app/build.gradle.kts | 3 +- .../dankchat/data/repo/chat/ChatRepository.kt | 2 +- .../main/compose/ChatInputViewModel.kt | 8 +- .../main/compose/FullScreenSheetOverlay.kt | 3 +- .../main/compose/MainScreenEventHandler.kt | 4 +- .../main/compose/dialogs/RoomStateDialog.kt | 30 ++-- .../ControlFocusInsetsAnimationCallback.kt | 73 ---------- .../insets/RootViewDeferringInsetsCallback.kt | 137 ------------------ ...anslateDeferringInsetsAnimationCallback.kt | 84 ----------- .../dankchat/utils/span/ImprovedBulletSpan.kt | 68 --------- .../utils/span/LongClickLinkMovementMethod.kt | 95 ------------ .../dankchat/utils/span/LongClickableSpan.kt | 8 - 12 files changed, 26 insertions(+), 489 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/insets/ControlFocusInsetsAnimationCallback.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/insets/RootViewDeferringInsetsCallback.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/insets/TranslateDeferringInsetsAnimationCallback.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/span/ImprovedBulletSpan.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickLinkMovementMethod.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickableSpan.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d4d6dede7..fb78bbf05 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,11 +65,12 @@ 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") } } 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 a60aa9017..ab2c08363 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 @@ -415,7 +415,7 @@ class ChatRepository( eventSubManager.reconnectIfNecessary() } - fun getLastMessage(): String? = lastMessage[activeChannel.value]?.withoutInvisibleChar + fun getLastMessage(): String? = activeChannel.value?.let { lastMessage[it]?.withoutInvisibleChar } fun fakeWhisperIfNecessary(input: String) { if (pubSubManager.connectedAndHasWhisperTopic) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 03dcca372..c279ea9a4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -198,7 +198,7 @@ class ChatInputViewModel( ) fun uiState(externalSheetState: StateFlow, externalMentionTab: StateFlow): StateFlow { - if (_uiState != null) return _uiState!! + _uiState?.let { return it } // Wire up external sheet state for whisper clearing viewModelScope.launch { @@ -242,7 +242,7 @@ class ChatInputViewModel( InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget) } - _uiState = combine( + return combine( baseFlow, inputOverlayFlow, helperText, @@ -300,9 +300,7 @@ class ChatInputViewModel( isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, ), ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()) - - return _uiState!! + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()).also { _uiState = it } } fun sendMessage() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index b3c64d859..b06000333 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -175,8 +175,9 @@ fun FullScreenSheetOverlay( } is FullScreenSheetState.History -> { + val viewModel = currentHistoryViewModel ?: lastHistoryViewModel ?: return@AnimatedVisibility MessageHistorySheet( - viewModel = (currentHistoryViewModel ?: lastHistoryViewModel)!!, + viewModel = viewModel, channel = renderState.channel, initialFilter = renderState.initialFilter, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index a3dd11d5d..99ec8c990 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -113,8 +113,8 @@ fun MainScreenEventHandler( // Handle data loading errors val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() LaunchedEffect(loadingState) { - if (loadingState is GlobalLoadingState.Failed) { - val state = loadingState as GlobalLoadingState.Failed + val state = loadingState as? GlobalLoadingState.Failed + if (state != null) { launch { snackbarHostState.showSnackbar( message = state.message, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt index 9af7dbbbe..cd94660a9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt @@ -30,6 +30,8 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import com.flxrs.dankchat.data.twitch.message.RoomState +private data class ParameterDialogConfig(val titleRes: Int, val hintRes: Int, val defaultValue: String, val commandPrefix: String) + private enum class ParameterDialogType { SLOW_MODE, FOLLOWER_MODE @@ -45,32 +47,32 @@ fun RoomStateDialog( var parameterDialog by remember { mutableStateOf(null) } parameterDialog?.let { type -> - val (title, hint, defaultValue, commandPrefix) = when (type) { - ParameterDialogType.SLOW_MODE -> listOf( - R.string.room_state_slow_mode, - R.string.seconds, - "30", - "/slow" + val (titleRes, hintRes, defaultValue, commandPrefix) = when (type) { + ParameterDialogType.SLOW_MODE -> ParameterDialogConfig( + titleRes = R.string.room_state_slow_mode, + hintRes = R.string.seconds, + defaultValue = "30", + commandPrefix = "/slow" ) - ParameterDialogType.FOLLOWER_MODE -> listOf( - R.string.room_state_follower_only, - R.string.minutes, - "10", - "/followers" + ParameterDialogType.FOLLOWER_MODE -> ParameterDialogConfig( + titleRes = R.string.room_state_follower_only, + hintRes = R.string.minutes, + defaultValue = "10", + commandPrefix = "/followers" ) } - var inputValue by remember(type) { mutableStateOf(defaultValue as String) } + var inputValue by remember(type) { mutableStateOf(defaultValue) } AlertDialog( onDismissRequest = { parameterDialog = null }, - title = { Text(stringResource(title as Int)) }, + title = { Text(stringResource(titleRes)) }, text = { OutlinedTextField( value = inputValue, onValueChange = { inputValue = it }, - label = { Text(stringResource(hint as Int)) }, + label = { Text(stringResource(hintRes)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, modifier = Modifier.fillMaxWidth() 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) -} From 3f399cde15eadf1208a471bde87c3e04871f3598 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 17 Mar 2026 13:45:29 +0100 Subject: [PATCH 070/349] fix(compose): Prepend https:// scheme to schemeless URLs in linkification --- .../com/flxrs/dankchat/chat/compose/Linkification.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt index f484cedc3..65489a481 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt @@ -36,17 +36,21 @@ fun AnnotatedString.Builder.appendWithLinks(text: String, linkColor: Color, prev } end = fixedEnd - val url = text.substring(start, end) + val rawUrl = text.substring(start, end) + val url = when { + rawUrl.contains("://") -> rawUrl + else -> "https://$rawUrl" + } // Append text before URL if (start > lastIndex) { append(text.substring(lastIndex, start)) } - // Append URL with annotation and style + // Append URL with annotation and style — annotation has full URL, display shows original text pushStringAnnotation(tag = "URL", annotation = url) withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { - append(url) + append(rawUrl) } pop() From 91c6401527ec8b816015f4049b8b4152656406aa Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 17 Mar 2026 14:42:12 +0100 Subject: [PATCH 071/349] refactor(tour): Replace FeatureTourController and PostOnboardingCoordinator with FeatureTourViewModel --- app/build.gradle.kts | 5 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 89 ++-- .../onboarding/OnboardingDataStore.kt | 16 +- .../dankchat/onboarding/OnboardingScreen.kt | 5 +- .../onboarding/OnboardingViewModel.kt | 13 +- .../developer/DeveloperSettingsScreen.kt | 14 + .../developer/DeveloperSettingsViewModel.kt | 25 + .../dankchat/tour/FeatureTourController.kt | 169 ------- .../dankchat/tour/FeatureTourViewModel.kt | 253 ++++++++++ .../tour/PostOnboardingCoordinator.kt | 86 ---- app/src/main/res/values/strings.xml | 7 +- .../data/repo/emote/EmoteRepositoryTest.kt | 4 + .../dankchat/tour/FeatureTourViewModelTest.kt | 445 ++++++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 7 +- 15 files changed, 815 insertions(+), 324 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModel.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb78bbf05..1eaf33cb3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.about.libraries.android) + alias(libs.plugins.android.junit5) } android { @@ -213,9 +214,11 @@ dependencies { // 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) } fun gradleLocalProperties(projectRootDir: File, providers: ProviderFactory): Properties { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index fd90300db..c807fe787 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -55,7 +55,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.rememberTooltipState + import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -110,14 +110,12 @@ import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.main.compose.sheets.EmoteMenu -import com.flxrs.dankchat.onboarding.OnboardingDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore +import com.flxrs.dankchat.tour.FeatureTourViewModel import com.flxrs.dankchat.tour.PostOnboardingStep import com.flxrs.dankchat.tour.TourStep -import com.flxrs.dankchat.tour.rememberFeatureTourController -import com.flxrs.dankchat.tour.rememberPostOnboardingCoordinator import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce @@ -162,11 +160,9 @@ fun MainScreen( val dialogViewModel: DialogStateViewModel = koinViewModel() val mentionViewModel: MentionComposeViewModel = koinViewModel() val preferenceStore: DankChatPreferenceStore = koinInject() - val onboardingDataStore: OnboardingDataStore = koinInject() val mainEventBus: MainEventBus = koinInject() - val tourController = rememberFeatureTourController(onboardingDataStore) - tourController.onHideInput = { mainScreenViewModel.setGestureInputHidden(true) } - tourController.onRestoreInput = { mainScreenViewModel.setGestureInputHidden(false) } + val featureTourViewModel: FeatureTourViewModel = koinViewModel() + val featureTourState by featureTourViewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -290,35 +286,36 @@ fun MainScreen( val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel // Post-onboarding flow: toolbar hint → feature tour - val coordinator = rememberPostOnboardingCoordinator(onboardingDataStore) - tourController.onComplete = coordinator::onTourCompleted - val postOnboardingStep = coordinator.step - val toolbarAddChannelTooltipState = rememberTooltipState(isPersistent = true) val channelsReady = !tabState.loading val channelsEmpty = tabState.tabs.isEmpty() && channelsReady - // Notify coordinator when channel state changes + // Notify tour VM when channel state changes LaunchedEffect(channelsReady, channelsEmpty) { - coordinator.onChannelsChanged(empty = channelsEmpty, ready = channelsReady) + featureTourViewModel.onChannelsChanged(empty = channelsEmpty, ready = channelsReady) } // Drive tooltip dismissals and tour start from the typed step. // Tooltip .show() calls live in FloatingToolbar. - LaunchedEffect(postOnboardingStep) { - when (postOnboardingStep) { + LaunchedEffect(featureTourState.postOnboardingStep) { + when (featureTourState.postOnboardingStep) { PostOnboardingStep.FeatureTour -> { - toolbarAddChannelTooltipState.dismiss() - tourController.start() + featureTourViewModel.addChannelTooltipState.dismiss() + featureTourViewModel.startTour() } PostOnboardingStep.Complete, PostOnboardingStep.Idle -> { - toolbarAddChannelTooltipState.dismiss() + featureTourViewModel.addChannelTooltipState.dismiss() } PostOnboardingStep.ToolbarPlusHint -> Unit } } + // Sync tour's gestureInputHidden with MainScreenViewModel + LaunchedEffect(featureTourState.gestureInputHidden) { + mainScreenViewModel.setGestureInputHidden(featureTourState.gestureInputHidden) + } + MainScreenDialogs( dialogViewModel = dialogViewModel, isLoggedIn = isLoggedIn, @@ -421,15 +418,15 @@ fun MainScreen( val effectiveShowAppBar = mainState.effectiveShowAppBar // Auto-advance tour when input is hidden during the SwipeGesture step (e.g. by actual swipe) - LaunchedEffect(mainState.gestureInputHidden, tourController.currentStep) { - if (mainState.gestureInputHidden && tourController.currentStep == TourStep.SwipeGesture) { - tourController.advance() + LaunchedEffect(mainState.gestureInputHidden, featureTourState.currentTourStep) { + if (mainState.gestureInputHidden && featureTourState.currentTourStep == TourStep.SwipeGesture) { + featureTourViewModel.advance() } } // Keep toolbar visible during tour - LaunchedEffect(tourController.isActive, mainState.gestureToolbarHidden) { - if (tourController.isActive && mainState.gestureToolbarHidden) { + LaunchedEffect(featureTourState.isTourActive, mainState.gestureToolbarHidden) { + if (featureTourState.isTourActive && mainState.gestureToolbarHidden) { mainScreenViewModel.setGestureToolbarHidden(false) } } @@ -591,13 +588,13 @@ fun MainScreen( onInputHeightChanged = { inputHeightPx = it }, instantHide = isHistorySheet, tourState = TourOverlayState( - inputActionsTooltipState = if (tourController.currentStep == TourStep.InputActions) tourController.inputActionsTooltipState else null, - overflowMenuTooltipState = if (tourController.currentStep == TourStep.OverflowMenu) tourController.overflowMenuTooltipState else null, - configureActionsTooltipState = if (tourController.currentStep == TourStep.ConfigureActions) tourController.configureActionsTooltipState else null, - swipeGestureTooltipState = if (tourController.currentStep == TourStep.SwipeGesture) tourController.swipeGestureTooltipState else null, - forceOverflowOpen = tourController.forceOverflowOpen, - onAdvance = tourController::advance, - onSkip = tourController::skipTour, + 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, + onAdvance = featureTourViewModel::advance, + onSkip = featureTourViewModel::skipTour, ), ) } @@ -616,7 +613,7 @@ fun MainScreen( } ToolbarAction.AddChannel -> { - coordinator.onAddedChannelFromToolbar() + featureTourViewModel.onAddedChannelFromToolbar() dialogViewModel.showAddChannel() } @@ -673,9 +670,9 @@ fun MainScreen( onAction = handleToolbarAction, endAligned = endAligned, showTabs = showTabs, - addChannelTooltipState = if (postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) toolbarAddChannelTooltipState else null, - onAddChannelTooltipDismissed = coordinator::onToolbarHintDismissed, - onSkipTour = tourController::skipTour, + addChannelTooltipState = if (featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) featureTourViewModel.addChannelTooltipState else null, + onAddChannelTooltipDismissed = featureTourViewModel::onToolbarHintDismissed, + onSkipTour = featureTourViewModel::skipTour, streamToolbarAlpha = streamState.effectiveAlpha, modifier = toolbarModifier, ) @@ -754,12 +751,12 @@ fun MainScreen( onUserClick = { userId, userName, displayName, channel, badges, _ -> dialogViewModel.showUserPopup( UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - )) + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + )) }, onMessageLongClick = { messageId, channel, fullMessage -> dialogViewModel.showMessageOptions( @@ -801,9 +798,9 @@ fun MainScreen( onScrollDirectionChanged = { }, scrollToMessageId = scrollTargets[channel], onScrollToMessageHandled = { scrollTargets.remove(channel) }, - recoveryFabTooltipState = if (tourController.currentStep == TourStep.RecoveryFab) tourController.recoveryFabTooltipState else null, - onTourAdvance = tourController::advance, - onTourSkip = tourController::skipTour, + recoveryFabTooltipState = if (featureTourState.currentTourStep == TourStep.RecoveryFab) featureTourViewModel.recoveryFabTooltipState else null, + onTourAdvance = featureTourViewModel::advance, + onTourSkip = featureTourViewModel::skipTour, ) } } @@ -963,7 +960,7 @@ fun MainScreen( indication = null, interactionSource = remember { MutableInteractionSource() }, ) { - if (!tourController.forceOverflowOpen) { + if (!featureTourState.forceOverflowOpen) { inputOverflowExpanded = false } } @@ -1086,7 +1083,7 @@ fun MainScreen( indication = null, interactionSource = remember { MutableInteractionSource() }, ) { - if (!tourController.forceOverflowOpen) { + if (!featureTourState.forceOverflowOpen) { inputOverflowExpanded = false } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt index dab65442f..4fe82a16d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt @@ -11,7 +11,6 @@ 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 @@ -40,32 +39,27 @@ class OnboardingDataStore( 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 = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + scope = scope, migrations = listOf(existingUserMigration), ) val settings = dataStore.safeData(OnboardingSettings()) val currentSettings = settings.stateIn( - scope = CoroutineScope(dispatchersProvider.io), + scope = scope, started = SharingStarted.Eagerly, initialValue = runBlocking { settings.first() } ) - private val persistScope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) - fun current() = currentSettings.value suspend fun update(transform: suspend (OnboardingSettings) -> OnboardingSettings) { - runCatching { dataStore.updateData(transform) } - } - - /** Fire-and-forget update that survives caller cancellation (e.g. config change). */ - fun updateAsync(transform: suspend (OnboardingSettings) -> OnboardingSettings) { - persistScope.launch { update(transform) } + dataStore.updateData(transform) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt index d280fddcd..7758129bf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt @@ -135,10 +135,7 @@ fun OnboardingScreen( 3 -> NotificationsPage( onContinue = { - scope.launch { - viewModel.completeOnboarding() - onComplete() - } + viewModel.completeOnboarding(onComplete) }, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt index f92119ff0..550ebc50e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking + import org.koin.android.annotation.KoinViewModel data class OnboardingState( @@ -35,7 +35,7 @@ class OnboardingViewModel( val state: StateFlow init { - val savedPage = runBlocking { onboardingDataStore.current().onboardingPage } + val savedPage = onboardingDataStore.current().onboardingPage val isLoggedIn = authDataStore.isLoggedIn _state = MutableStateFlow( OnboardingState( @@ -76,10 +76,13 @@ class OnboardingViewModel( _state.update { it.copy(messageHistoryDecided = true, messageHistoryEnabled = enabled) } } - suspend fun completeOnboarding() { + fun completeOnboarding(onComplete: () -> Unit) { val historyEnabled = _state.value.messageHistoryEnabled dankChatPreferenceStore.hasMessageHistoryAcknowledged = true - chatSettingsDataStore.update { it.copy(loadMessageHistory = historyEnabled) } - onboardingDataStore.update { it.copy(hasCompletedOnboarding = true, onboardingPage = 0) } + 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/preferences/developer/DeveloperSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt index 90765d5c1..b40fcf07a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -70,6 +70,7 @@ 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.PreferenceItem import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem import com.flxrs.dankchat.preferences.developer.DeveloperSettingsInteraction.EventSubDebugOutput import com.flxrs.dankchat.preferences.developer.DeveloperSettingsInteraction.EventSubEnabled @@ -206,6 +207,19 @@ private fun DeveloperSettingsContent( ) } + 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() } } 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..db3877cf2 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,6 +2,7 @@ package com.flxrs.dankchat.preferences.developer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.onboarding.OnboardingDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.utils.extensions.withTrailingSlash import kotlinx.coroutines.flow.MutableSharedFlow @@ -18,6 +19,7 @@ import kotlin.time.Duration.Companion.seconds class DeveloperSettingsViewModel( private val developerSettingsDataStore: DeveloperSettingsDataStore, private val dankchatPreferenceStore: DankChatPreferenceStore, + private val onboardingDataStore: OnboardingDataStore, ) : ViewModel() { private val initial = developerSettingsDataStore.current() @@ -54,6 +56,27 @@ class DeveloperSettingsViewModel( is DeveloperSettingsInteraction.EventSubDebugOutput -> developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } 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) + } } } } @@ -71,6 +94,8 @@ sealed interface DeveloperSettingsInteraction { data class EventSubEnabled(val value: Boolean) : DeveloperSettingsInteraction data class EventSubDebugOutput(val value: Boolean) : DeveloperSettingsInteraction data object RestartRequired : DeveloperSettingsInteraction + data object ResetOnboarding : DeveloperSettingsInteraction + data object ResetTour : DeveloperSettingsInteraction } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt deleted file mode 100644 index df1fe7899..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourController.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.flxrs.dankchat.tour - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.TooltipState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -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 com.flxrs.dankchat.onboarding.OnboardingDataStore -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -const val CURRENT_TOUR_VERSION = 1 - -enum class TourStep { - InputActions, - OverflowMenu, - ConfigureActions, - SwipeGesture, - RecoveryFab, -} - -@OptIn(ExperimentalMaterial3Api::class) -@Stable -class FeatureTourController( - private val onboardingDataStore: OnboardingDataStore, - private val scope: CoroutineScope, -) { - var isActive by mutableStateOf(false) - private set - - /** Set synchronously on completion to prevent restart from stale datastore reads. */ - private var hasCompleted = false - - var currentStepIndex by mutableIntStateOf(0) - private set - - val currentStep: TourStep? - get() = when { - !isActive -> null - currentStepIndex >= TourStep.entries.size -> null - else -> TourStep.entries[currentStepIndex] - } - - /** When true, ChatInputLayout should force the overflow menu open. */ - var forceOverflowOpen by mutableStateOf(false) - private set - - /** Set by MainScreen to hide input for the RecoveryFab step. */ - var onHideInput: (() -> Unit)? = null - - /** Set by MainScreen to restore input when the tour completes. */ - var onRestoreInput: (() -> Unit)? = null - - /** Called when the tour finishes (either completed or skipped). */ - var onComplete: (() -> Unit)? = null - - 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) - - fun start() { - if (isActive || hasCompleted) return - isActive = true - 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. - currentStepIndex = when { - CURRENT_TOUR_VERSION - settings.featureTourVersion == 1 -> settings.featureTourStep.coerceIn(0, TourStep.entries.size - 1) - else -> 0 - } - applyStepSideEffects() - showCurrentTooltip() - } - - fun advance() { - val leavingStep = currentStep - // 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 (leavingStep != TourStep.ConfigureActions) { - dismissCurrentTooltip() - } - currentStepIndex++ - val nextStep = currentStep - when { - nextStep == null -> { - completeTour() - return - } - - else -> { - onboardingDataStore.updateAsync { it.copy(featureTourStep = currentStepIndex) } - applyStepSideEffects() - } - } - if (leavingStep == TourStep.ConfigureActions) { - // Menu close animation takes ~150ms; show the next tooltip after it finishes - scope.launch { - delay(250) - showCurrentTooltip() - } - } else { - showCurrentTooltip() - } - } - - private fun applyStepSideEffects() { - when (currentStep) { - TourStep.ConfigureActions -> forceOverflowOpen = true - TourStep.SwipeGesture -> forceOverflowOpen = false - TourStep.RecoveryFab -> onHideInput?.invoke() - else -> {} - } - } - - fun skipTour() { - dismissCurrentTooltip() - forceOverflowOpen = false - completeTour() - } - - private fun completeTour() { - isActive = false - hasCompleted = true - onRestoreInput?.invoke() - onComplete?.invoke() - onboardingDataStore.updateAsync { it.copy(featureTourVersion = CURRENT_TOUR_VERSION, featureTourStep = 0) } - } - - private fun showCurrentTooltip() { - val state = tooltipStateForStep(currentStep ?: return) - scope.launch { state.show() } - } - - private fun dismissCurrentTooltip() { - val step = currentStep ?: return - tooltipStateForStep(step).dismiss() - } - - private fun tooltipStateForStep(step: TourStep): TooltipState = when (step) { - TourStep.InputActions -> inputActionsTooltipState - TourStep.OverflowMenu -> overflowMenuTooltipState - TourStep.ConfigureActions -> configureActionsTooltipState - TourStep.SwipeGesture -> swipeGestureTooltipState - TourStep.RecoveryFab -> recoveryFabTooltipState - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun rememberFeatureTourController( - onboardingDataStore: OnboardingDataStore, -): FeatureTourController { - val scope = rememberCoroutineScope() - return remember(onboardingDataStore) { - FeatureTourController( - onboardingDataStore = onboardingDataStore, - scope = scope, - ) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModel.kt new file mode 100644 index 000000000..caa5475fd --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModel.kt @@ -0,0 +1,253 @@ +package com.flxrs.dankchat.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.onboarding.OnboardingDataStore +import com.flxrs.dankchat.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.android.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, +) : ViewModel() { + + // Material3 tooltip states — UI objects exposed directly, not in the StateFlow. + 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, + ) { settings, tour, channel, hintDone -> + 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, + ), + 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) + } + + // -- Toolbar hint callbacks -- + + /** 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) } + } + } + + // -- Tour lifecycle -- + + 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, + ): PostOnboardingStep = when { + tourCompleted -> PostOnboardingStep.Complete + settings.featureTourVersion >= CURRENT_TOUR_VERSION && toolbarHintDone -> PostOnboardingStep.Complete + !settings.hasCompletedOnboarding -> 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/tour/PostOnboardingCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt deleted file mode 100644 index 3af647070..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/tour/PostOnboardingCoordinator.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.flxrs.dankchat.tour - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -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 com.flxrs.dankchat.onboarding.OnboardingDataStore - -@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 -} - -@Stable -class PostOnboardingCoordinator( - private val onboardingDataStore: OnboardingDataStore, -) { - var step by mutableStateOf(PostOnboardingStep.Idle) - private set - - private var channelsReady = false - private var isEmpty = true - private var toolbarHintDone = false - - fun onChannelsChanged(empty: Boolean, ready: Boolean) { - isEmpty = empty - channelsReady = ready - resolveStep() - } - - /** User already used the toolbar + icon, no need to show the hint. */ - fun onAddedChannelFromToolbar() { - if (toolbarHintDone) return - toolbarHintDone = true - onboardingDataStore.updateAsync { it.copy(hasShownToolbarHint = true) } - } - - fun onToolbarHintDismissed() { - if (toolbarHintDone) return // idempotent for external-dismiss handler - toolbarHintDone = true - onboardingDataStore.updateAsync { it.copy(hasShownToolbarHint = true) } - val settings = onboardingDataStore.current() - step = when { - settings.featureTourVersion < CURRENT_TOUR_VERSION && !isEmpty -> PostOnboardingStep.FeatureTour - else -> PostOnboardingStep.Complete - } - } - - fun onTourCompleted() { - step = PostOnboardingStep.Complete - } - - private fun resolveStep() { - if (!channelsReady || step is PostOnboardingStep.Complete) return - val settings = onboardingDataStore.current() - val toolbarDone = settings.hasShownToolbarHint || toolbarHintDone - - step = when { - !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle - isEmpty -> PostOnboardingStep.Idle - !toolbarDone -> PostOnboardingStep.ToolbarPlusHint - settings.featureTourVersion < CURRENT_TOUR_VERSION -> PostOnboardingStep.FeatureTour - else -> PostOnboardingStep.Complete - } - } -} - -@Composable -fun rememberPostOnboardingCoordinator(onboardingDataStore: OnboardingDataStore): PostOnboardingCoordinator { - return remember(onboardingDataStore) { - PostOnboardingCoordinator(onboardingDataStore) - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd282add5..61f4b8dcc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -233,7 +233,7 @@ Fullscreen Exit fullscreen Hide input -Channel settings + Channel settings Maximum of %1$d action Maximum of %1$d actions @@ -436,6 +436,11 @@ Disables filtering of unapproved or unlisted emotes rm_host_key Custom recent messages host + 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. 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..55a5d349d 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,6 +1,7 @@ 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 @@ -18,6 +19,9 @@ internal class EmoteRepositoryTest { @MockK lateinit var dankchatApiClient: DankChatApiClient + @MockK + lateinit var helixApiClient: HelixApiClient + @MockK lateinit var chatSettings: ChatSettingsDataStore diff --git a/app/src/test/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModelTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModelTest.kt new file mode 100644 index 000000000..435457d73 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModelTest.kt @@ -0,0 +1,445 @@ +package com.flxrs.dankchat.tour + +import app.cash.turbine.test +import com.flxrs.dankchat.onboarding.OnboardingDataStore +import com.flxrs.dankchat.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) + } + + viewModel = FeatureTourViewModel(onboardingDataStore) + } + + @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/build.gradle.kts b/build.gradle.kts index 057be2a3f..bde020686 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,4 +11,5 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.about.libraries.android) apply false + alias(libs.plugins.android.junit5) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cce6209d..fd4ece3de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,7 @@ compose-material3-adaptive = "1.2.0" compose-unstyled = "1.49.6" material = "1.13.0" flexBox = "3.0.0" -autoLinkText = "2.0.2" +autoLinkText = "2.0.2" processPhoenix = "3.0.0" @@ -45,7 +45,9 @@ colorPicker = "3.1.0" reorderable = "2.4.3" 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" } @@ -134,6 +136,8 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re 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" } @@ -144,4 +148,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko nav-safeargs-kotlin = { id = "androidx.navigation.safeargs.kotlin", version.ref = "androidxNavigation" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } about-libraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "about-libraries" } +android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJunit5" } androidx-room = { id = "androidx.room", version.ref = "androidxRoom" } #TODO use me when working From 4c9ca132f7a6a7d6db5c8b01bc1b061ed13fa44e Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 17 Mar 2026 15:19:54 +0100 Subject: [PATCH 072/349] fix(compose): Remove fullscreen sheet workarounds, inline jump icons, and stability fixes --- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 60 ++++--------------- .../com/flxrs/dankchat/main/InputState.kt | 3 + .../main/compose/ChatInputViewModel.kt | 2 + .../dankchat/main/compose/FloatingToolbar.kt | 6 +- .../main/compose/FullScreenSheetOverlay.kt | 47 +++------------ .../flxrs/dankchat/main/compose/MainScreen.kt | 31 ++++------ .../compose/sheets/MessageHistorySheet.kt | 2 - 7 files changed, 40 insertions(+), 111 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 689267834..55fe1eb31 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew + import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.ExperimentalMaterial3Api @@ -100,7 +100,6 @@ fun ChatScreen( onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, scrollToMessageId: String? = null, onScrollToMessageHandled: () -> Unit = {}, - onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, onAutomodAllow: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, onAutomodDeny: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, containerColor: Color = MaterialTheme.colorScheme.background, @@ -204,7 +203,6 @@ fun ChatScreen( onEmoteClick = onEmoteClick, onReplyClick = onReplyClick, onWhisperReply = onWhisperReply, - onJumpToMessage = onJumpToMessage, onAutomodAllow = onAutomodAllow, onAutomodDeny = onAutomodDeny, ) @@ -345,7 +343,6 @@ private fun ChatMessageItem( onEmoteClick: (emotes: List) -> Unit, onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit, onWhisperReply: ((userName: UserName) -> Unit)? = null, - onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, onAutomodAllow: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, onAutomodDeny: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, ) { @@ -378,50 +375,17 @@ private fun ChatMessageItem( onDeny = onAutomodDeny, ) - is ChatMessageUiState.PrivMessageUi -> { - if (onJumpToMessage != null) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Box(modifier = Modifier.weight(1f)) { - PrivMessageComposable( - message = message, - highlightShape = highlightShape, - fontSize = fontSize, - showChannelPrefix = showChannelPrefix, - animateGifs = animateGifs, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick - ) - } - IconButton( - onClick = { onJumpToMessage(message.id, message.channel) }, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = stringResource(R.string.message_jump_to), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } else { - PrivMessageComposable( - message = message, - highlightShape = highlightShape, - fontSize = fontSize, - showChannelPrefix = showChannelPrefix, - animateGifs = animateGifs, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick - ) - } - } + is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( + message = message, + highlightShape = highlightShape, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick + ) is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( message = message, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt index 4eda14931..fa656ecff 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt @@ -1,5 +1,8 @@ package com.flxrs.dankchat.main +import androidx.compose.runtime.Stable + +@Stable sealed interface InputState { object Default : InputState object Replying : InputState diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index c279ea9a4..e25a7ac8c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -4,6 +4,7 @@ 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.Immutable +import androidx.compose.runtime.Stable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange import androidx.lifecycle.ViewModel @@ -527,6 +528,7 @@ data class ChatInputUiState( val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, ) +@Stable sealed interface CharacterCounterState { data object Hidden : CharacterCounterState diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index f3fea21c5..28b54bfe9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -86,6 +86,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first +private const val TAB_AUTO_COLLAPSE_DELAY_MS = 1000L + sealed interface ToolbarAction { data class SelectTab(val index: Int) : ToolbarAction data class LongClickTab(val index: Int) : ToolbarAction @@ -158,10 +160,10 @@ fun FloatingToolbar( } } - // Auto-collapse after all scrolling stops + 2s delay + // Auto-collapse after all scrolling stops LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress, tabListState.isScrollInProgress) { if (isTabsExpanded && !composePagerState.isScrollInProgress && !tabListState.isScrollInProgress) { - delay(2000) + delay(TAB_AUTO_COLLAPSE_DELAY_MS) isTabsExpanded = false } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index b06000333..ccb9001b4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -8,10 +8,6 @@ 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.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.unit.Dp @@ -43,31 +39,8 @@ fun FullScreenSheetOverlay( onEmoteClick: (List) -> Unit, modifier: Modifier = Modifier, onWhisperReply: (UserName) -> Unit = {}, - onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, bottomContentPadding: Dp = 0.dp, ) { - // Pre-resolve history VM outside AnimatedVisibility to avoid Koin creating - // duplicate instances during the enter animation (causes mid-animation stutter) - val historyState = sheetState as? FullScreenSheetState.History - val currentHistoryViewModel: MessageHistoryComposeViewModel? = historyState?.let { - koinViewModel( - key = it.channel.value, - parameters = { parametersOf(it.channel) }, - ) - } - - // Remember the last active (non-Closed) state and history VM so content persists - // during exit animation. Without this, when sheetState changes to Closed the `when` - // block would render nothing, causing a flash while the exit animation is still playing. - var lastActiveState by remember { mutableStateOf(sheetState) } - var lastHistoryViewModel by remember { mutableStateOf(currentHistoryViewModel) } - if (sheetState !is FullScreenSheetState.Closed) { - lastActiveState = sheetState - } - if (currentHistoryViewModel != null) { - lastHistoryViewModel = currentHistoryViewModel - } - val isVisible = sheetState !is FullScreenSheetState.Closed AnimatedVisibility( @@ -91,13 +64,7 @@ fun FullScreenSheetOverlay( ) } - // Use lastActiveState so content stays visible during the exit animation - val renderState = when { - isVisible -> sheetState - else -> lastActiveState - } - - when (renderState) { + when (sheetState) { is FullScreenSheetState.Closed -> Unit is FullScreenSheetState.Mention -> { MentionSheet( @@ -153,7 +120,7 @@ fun FullScreenSheetOverlay( is FullScreenSheetState.Replies -> { RepliesSheet( - rootMessageId = renderState.replyMessageId, + rootMessageId = sheetState.replyMessageId, onDismiss = onDismissReplies, onUserClick = userClickHandler, @@ -175,11 +142,14 @@ fun FullScreenSheetOverlay( } is FullScreenSheetState.History -> { - val viewModel = currentHistoryViewModel ?: lastHistoryViewModel ?: return@AnimatedVisibility + val viewModel: MessageHistoryComposeViewModel = koinViewModel( + key = sheetState.channel.value, + parameters = { parametersOf(sheetState.channel) }, + ) MessageHistorySheet( viewModel = viewModel, - channel = renderState.channel, - initialFilter = renderState.initialFilter, + channel = sheetState.channel, + initialFilter = sheetState.initialFilter, onDismiss = onDismiss, onUserClick = userClickHandler, @@ -197,7 +167,6 @@ fun FullScreenSheetOverlay( ) }, onEmoteClick = onEmoteClick, - onJumpToMessage = onJumpToMessage, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index c807fe787..e2958721d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -587,15 +587,17 @@ fun MainScreen( onOverflowExpandedChanged = { inputOverflowExpanded = it }, onInputHeightChanged = { inputHeightPx = it }, instantHide = isHistorySheet, - tourState = 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, - onAdvance = featureTourViewModel::advance, - onSkip = featureTourViewModel::skipTour, - ), + tourState = remember(featureTourState.currentTourStep, featureTourState.forceOverflowOpen) { + 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, + onAdvance = featureTourViewModel::advance, + onSkip = featureTourViewModel::skipTour, + ) + }, ) } @@ -861,17 +863,6 @@ fun MainScreen( onMessageLongClick = dialogViewModel::showMessageOptions, onEmoteClick = dialogViewModel::showEmoteInfo, onWhisperReply = chatInputViewModel::setWhisperTarget, - onJumpToMessage = { messageId, channel -> - val target = channelPagerViewModel.resolveJumpTarget(channel, messageId) - if (target != null) { - scrollTargets[target.channel] = target.messageId - sheetNavigationViewModel.closeFullScreenSheet() - } else { - scope.launch { - snackbarHostState.showSnackbar(messageNotInHistoryMsg) - } - } - }, bottomContentPadding = effectiveBottomPadding, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt index 443686ffa..53e7da0f7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -74,7 +74,6 @@ fun MessageHistorySheet( 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, - onJumpToMessage: ((messageId: String, channel: UserName) -> Unit)? = null, ) { LaunchedEffect(viewModel, initialFilter) { @@ -141,7 +140,6 @@ fun MessageHistorySheet( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, - onJumpToMessage = onJumpToMessage, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), containerColor = sheetBackgroundColor, ) From 6788335d111955c4ea2e8d5eea3dba771d970b3c Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 17 Mar 2026 15:28:00 +0100 Subject: [PATCH 073/349] fix(compose): Hide moderation, reply, and view thread actions in sheet message options --- .../com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt | 4 ++-- .../dankchat/main/compose/dialogs/MessageOptionsDialog.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt index ccb9001b4..b570a9398 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt @@ -130,8 +130,8 @@ fun FullScreenSheetOverlay( messageId = messageId, channel = channel?.let { UserName(it) }, fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, + canModerate = false, + canReply = false, canCopy = true, canJump = true, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt index 8cad9b36e..5b98f7706 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt @@ -85,7 +85,7 @@ fun MessageOptionsDialog( } ) } - if (hasReplyThread) { + if (canReply && hasReplyThread) { MessageOptionItem( icon = Icons.AutoMirrored.Filled.Reply, // Using same icon for thread view text = stringResource(R.string.message_view_thread), From 2ffba21ffbb382a7dbbc8a12831631c309b90b14 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 18 Mar 2026 10:39:17 +0100 Subject: [PATCH 074/349] fix(compose): Fix keyboard on stream close, tab scroll on collapse, and stream PiP transition --- .../com/flxrs/dankchat/main/compose/FloatingToolbar.kt | 8 ++++++++ .../kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index 28b54bfe9..75995a0f1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -168,6 +168,14 @@ fun FloatingToolbar( } } + // Scroll to selected tab after collapse animation settles + LaunchedEffect(isTabsExpanded, selectedIndex) { + if (!isTabsExpanded && hasOverflow) { + delay(400) // wait for action icons enter animation (350ms tween) + tabListState.animateScrollToItem(selectedIndex) + } + } + // Reset expanded state when toolbar hides (e.g. keyboard opens in split mode) LaunchedEffect(showAppBar) { if (!showAppBar) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index e2958721d..23fde6e0c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -889,6 +889,7 @@ fun MainScreen( streamViewModel = streamViewModel, fillPane = true, onClose = { + keyboardController?.hide() focusManager.clearFocus() streamViewModel.closeStream() }, @@ -1023,8 +1024,9 @@ fun MainScreen( // Stream View layer currentStream?.let { channel -> val showStream = isInPipMode || !isKeyboardVisible || isLandscape - // Delay adding StreamView to composition to prevent WebView flash - var streamComposed by remember { mutableStateOf(false) } + // Delay adding StreamView to composition to prevent WebView flash on first open. + // If the WebView was already attached (e.g. switching from wide layout), skip the delay. + var streamComposed by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } LaunchedEffect(showStream) { if (showStream) { delay(100) @@ -1039,6 +1041,7 @@ fun MainScreen( streamViewModel = streamViewModel, isInPipMode = isInPipMode, onClose = { + keyboardController?.hide() focusManager.clearFocus() streamViewModel.closeStream() }, From 1171b5ebe77b9b4bb7bdf43fed03c08e83f81416 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 18 Mar 2026 11:44:39 +0100 Subject: [PATCH 075/349] fix(compose): Resume stream playback after config change and fix focus on stream close --- .../com/flxrs/dankchat/main/compose/MainScreen.kt | 9 +++++++++ .../com/flxrs/dankchat/main/compose/StreamView.kt | 11 ++++++++++- .../flxrs/dankchat/main/compose/StreamViewModel.kt | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 23fde6e0c..5cebeeb5b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -497,6 +497,15 @@ fun MainScreen( } } + // 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() + } + } + // Sync Compose pager with ViewModel state LaunchedEffect(pagerState.currentPage, pagerState.channels.size) { if (!composePagerState.isScrollInProgress && diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt index e630575e2..defa73530 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier 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 @@ -98,10 +99,18 @@ fun StreamView( 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 = { view -> + update = { _ -> streamViewModel.setStream(channel, webView) }, modifier = webViewModifier diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt index 4c8b9e160..81426108d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt @@ -107,6 +107,7 @@ class StreamViewModel( _currentStreamedChannel.value = null } + override fun onCleared() { streamDataRepository.cancelStreamData() cachedWebView?.destroy() From 76335bbcfb48ee626bba5be0de5304a49da667a6 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 18 Mar 2026 12:01:06 +0100 Subject: [PATCH 076/349] fix(compose): Fix full-screen flash when opening stream via offscreen compositing --- .../kotlin/com/flxrs/dankchat/main/compose/StreamView.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt index defa73530..ce822bc0d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt @@ -26,6 +26,8 @@ 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 @@ -113,7 +115,9 @@ fun StreamView( update = { _ -> streamViewModel.setStream(channel, webView) }, - modifier = webViewModifier + modifier = webViewModifier.graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } ) } else { Box(modifier = webViewModifier) From 0f6281ddd523ba80d24efc63bf65e18cabf88430 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 12:46:33 +0100 Subject: [PATCH 077/349] fix(compose): Fix split layout padding for helper text, bottom spacing, and FABs --- .../dankchat/chat/compose/ChatComposable.kt | 2 -- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 7 +------ .../dankchat/main/compose/ChatBottomBar.kt | 17 ++++++++++++++--- .../flxrs/dankchat/main/compose/MainScreen.kt | 15 ++++++++++----- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt index d509d24fd..3d422597d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt @@ -37,7 +37,6 @@ fun ChatComposable( modifier: Modifier = Modifier, showInput: Boolean = true, isFullscreen: Boolean = false, - hasHelperText: Boolean = false, showFabs: Boolean = true, onRecover: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(), @@ -76,7 +75,6 @@ fun ChatComposable( onReplyClick = onReplyClick, showInput = showInput, isFullscreen = isFullscreen, - hasHelperText = hasHelperText, showFabs = showFabs, onRecover = onRecover, contentPadding = contentPadding, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 55fe1eb31..39be0f9d2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -92,7 +92,6 @@ fun ChatScreen( onWhisperReply: ((userName: UserName) -> Unit)? = null, showInput: Boolean = true, isFullscreen: Boolean = false, - hasHelperText: Boolean = false, onRecover: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(), scrollModifier: Modifier = Modifier, @@ -219,11 +218,7 @@ fun ChatScreen( val showScrollFab = !shouldAutoScroll && messages.isNotEmpty() val bottomContentPadding = contentPadding.calculateBottomPadding() val fabBottomPadding by animateDpAsState( - targetValue = when { - showInput -> bottomContentPadding - hasHelperText -> maxOf(bottomContentPadding, 48.dp) - else -> maxOf(bottomContentPadding, 24.dp) - }, + targetValue = bottomContentPadding, animationSpec = if (showInput) snap() else spring(), label = "fabBottomPadding" ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index d4ed435d9..1f7a57a06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.basicMarquee 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 @@ -19,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.flxrs.dankchat.preferences.appearance.InputAction @@ -55,6 +57,8 @@ fun ChatBottomBar( overflowExpanded: Boolean = false, onOverflowExpandedChanged: (Boolean) -> Unit = {}, onInputHeightChanged: (Int) -> Unit, + onHelperTextHeightChanged: (Int) -> Unit = {}, + isInSplitLayout: Boolean = false, instantHide: Boolean = false, tourState: TourOverlayState = TourOverlayState(), ) { @@ -114,12 +118,19 @@ fun ChatBottomBar( val helperText = inputState.helperText if (!helperText.isNullOrEmpty()) { val horizontalPadding = when { - isFullscreen -> rememberRoundedCornerHorizontalPadding(fallback = 16.dp) - else -> PaddingValues(horizontal = 16.dp) + 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, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { onHelperTextHeightChanged(it.size.height) } ) { Text( text = helperText, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 5cebeeb5b..9ed300608 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -476,9 +476,12 @@ fun MainScreen( 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) } if (!effectiveShowInput) inputHeightPx = 0 + if (effectiveShowInput || inputState.helperText.isNullOrEmpty()) helperTextHeightPx = 0 val inputHeightDp = with(density) { inputHeightPx.toDp() } + val helperTextHeightDp = with(density) { helperTextHeightPx.toDp() } // scaffoldBottomContentPadding removed — input bar rendered outside Scaffold // Clear focus when keyboard fully reaches the bottom, but not when @@ -595,6 +598,8 @@ fun MainScreen( overflowExpanded = inputOverflowExpanded, onOverflowExpandedChanged = { inputOverflowExpanded = it }, onInputHeightChanged = { inputHeightPx = it }, + onHelperTextHeightChanged = { helperTextHeightPx = it }, + isInSplitLayout = useWideSplitLayout, instantHide = isHistorySheet, tourState = remember(featureTourState.currentTourStep, featureTourState.forceOverflowOpen) { TourOverlayState( @@ -789,7 +794,6 @@ fun MainScreen( }, showInput = effectiveShowInput, isFullscreen = isFullscreen, - hasHelperText = !inputState.helperText.isNullOrEmpty(), showFabs = !isSheetOpen, onRecover = { if (isFullscreen) mainScreenViewModel.toggleFullscreen() @@ -798,10 +802,11 @@ fun MainScreen( }, contentPadding = PaddingValues( top = chatTopPadding + 56.dp, - bottom = paddingValues.calculateBottomPadding() + inputHeightDp + when { - !effectiveShowInput && !isFullscreen -> max(navBarHeightDp, roundedCornerBottomPadding) - !effectiveShowInput -> roundedCornerBottomPadding - else -> 0.dp + bottom = paddingValues.calculateBottomPadding() + when { + effectiveShowInput -> inputHeightDp + !isFullscreen -> max(helperTextHeightDp, max(navBarHeightDp, roundedCornerBottomPadding)) + useWideSplitLayout -> helperTextHeightDp + else -> max(helperTextHeightDp, roundedCornerBottomPadding) } ), scrollModifier = chatScrollModifier, From b666a964ee0a93d933208a7845fcd6f4a62b1bb2 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 13:01:19 +0100 Subject: [PATCH 078/349] fix(compose): Always close stream on toggle and remove fling input reveal --- .../chat/compose/ChatScrollBehavior.kt | 25 ------------------- .../flxrs/dankchat/main/compose/MainScreen.kt | 20 +++++++-------- 2 files changed, 10 insertions(+), 35 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt index abfc11fde..b3779cc22 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt @@ -49,31 +49,6 @@ class ScrollDirectionTracker( } } -/** - * Detects sustained overscroll at the bottom of a reversed list. - * Requires [frameThreshold] consecutive overscroll events (~16ms each - * during a drag) before calling [onReveal], so only a deliberate, - * sustained pull triggers it — not a scroll that merely reaches the end. - */ -fun overscrollRevealConnection(frameThreshold: Int, onReveal: () -> Unit): NestedScrollConnection { - return object : NestedScrollConnection { - private var consecutiveFrames = 0 - - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - if (source == NestedScrollSource.UserInput && available.y < 0f) { - consecutiveFrames++ - if (consecutiveFrames >= frameThreshold) { - onReveal() - consecutiveFrames = 0 - } - } else { - consecutiveFrames = 0 - } - return Offset.Zero - } - } -} - /** * Detects a cumulative downward drag exceeding [thresholdPx] and calls [onHide]. * Uses [PointerEventPass.Initial] to observe events before children (text fields, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 9ed300608..313b03cda 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -101,7 +101,6 @@ import androidx.window.core.layout.WindowSizeClass import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.ChatComposable import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker -import com.flxrs.dankchat.chat.compose.overscrollRevealConnection import com.flxrs.dankchat.chat.compose.swipeDownToHide import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams @@ -439,15 +438,8 @@ fun MainScreen( onShow = { mainScreenViewModel.setGestureToolbarHidden(false) }, ) } - val overscrollReveal = remember { - overscrollRevealConnection( - frameThreshold = 15, - onReveal = { mainScreenViewModel.setGestureInputHidden(false) }, - ) - } val chatScrollModifier = Modifier .nestedScroll(toolbarTracker) - .nestedScroll(overscrollReveal) val swipeDownThresholdPx = with(density) { 56.dp.toPx() } @@ -588,7 +580,12 @@ fun MainScreen( onReplyDismiss = { chatInputViewModel.setReplying(false) }, onToggleFullscreen = mainScreenViewModel::toggleFullscreen, onToggleInput = mainScreenViewModel::toggleInput, - onToggleStream = { activeChannel?.let { streamViewModel.toggleStream(it) } }, + onToggleStream = { + when { + currentStream != null -> streamViewModel.closeStream() + else -> activeChannel?.let { streamViewModel.toggleStream(it) } + } + }, onChangeRoomState = dialogViewModel::showRoomState, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, onNewWhisper = if (inputState.isWhisperTabActive) { @@ -665,7 +662,10 @@ fun MainScreen( } ToolbarAction.ClearChat -> dialogViewModel.showClearChat() - ToolbarAction.ToggleStream -> activeChannel?.let { streamViewModel.toggleStream(it) } + ToolbarAction.ToggleStream -> when { + currentStream != null -> streamViewModel.closeStream() + else -> activeChannel?.let { streamViewModel.toggleStream(it) } + } ToolbarAction.OpenSettings -> onNavigateToSettings() ToolbarAction.MessageHistory -> activeChannel?.let { sheetNavigationViewModel.openHistory(it) } } From ca4111c0280b7ef5d762e5002eaf351c6c9c4eed Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 13:18:59 +0100 Subject: [PATCH 079/349] fix(compose): Persist gesture input, fix auto-scroll, clear mentions on sheet, and fix tab indicators --- .../com/flxrs/dankchat/chat/compose/ChatScreen.kt | 8 +++++--- .../dankchat/main/compose/ChannelTabViewModel.kt | 4 ++++ .../flxrs/dankchat/main/compose/FloatingToolbar.kt | 7 ++++--- .../com/flxrs/dankchat/main/compose/MainScreen.kt | 14 ++++++++++---- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 39be0f9d2..39c4a176c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -112,11 +112,13 @@ fun ChatScreen( // 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) + // 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 { - val firstVisibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() - firstVisibleItem?.index == 0 + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt index ae8eef270..885a7de16 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt @@ -75,6 +75,10 @@ class ChannelTabViewModel( chatRepository.clearMentionCount(channel) } } + + fun clearAllMentionCounts() { + chatRepository.clearMentionCounts() + } } @Immutable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index 75995a0f1..d420833a5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -257,15 +257,16 @@ fun FloatingToolbar( } } - // Mention indicators based on visibility - val hasLeftMention by remember { + // Mention indicators based on visibility (keyed on tabs so the + // derivedStateOf recaptures when mention counts change) + val hasLeftMention by remember(tabState.tabs) { derivedStateOf { val visibleItems = tabListState.layoutInfo.visibleItemsInfo val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } } } - val hasRightMention by remember { + val hasRightMention by remember(tabState.tabs) { derivedStateOf { val visibleItems = tabListState.layoutInfo.visibleItemsInfo val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 313b03cda..4ecaebe28 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -310,9 +310,12 @@ fun MainScreen( } } - // Sync tour's gestureInputHidden with MainScreenViewModel - LaunchedEffect(featureTourState.gestureInputHidden) { - mainScreenViewModel.setGestureInputHidden(featureTourState.gestureInputHidden) + // Sync tour's gestureInputHidden with MainScreenViewModel (only during active tour + // to avoid resetting the persisted state on Activity recreation) + LaunchedEffect(featureTourState.gestureInputHidden, featureTourState.isTourActive) { + if (featureTourState.isTourActive) { + mainScreenViewModel.setGestureInputHidden(featureTourState.gestureInputHidden) + } } MainScreenDialogs( @@ -630,7 +633,10 @@ fun MainScreen( dialogViewModel.showAddChannel() } - ToolbarAction.OpenMentions -> sheetNavigationViewModel.openMentions() + ToolbarAction.OpenMentions -> { + sheetNavigationViewModel.openMentions() + channelTabViewModel.clearAllMentionCounts() + } ToolbarAction.Login -> onLogin() ToolbarAction.Relogin -> onRelogin() ToolbarAction.Logout -> dialogViewModel.showLogout() From 9e7eddb33e8f89db122322274fa3bbd68d10ef50 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 15:33:12 +0100 Subject: [PATCH 080/349] fix(chat): Use deterministic IDs for CLEARCHAT/CLEARMSG to fix reconnect duplicates --- .../com/flxrs/dankchat/data/repo/chat/ChatRepository.kt | 1 + .../flxrs/dankchat/data/twitch/message/ModerationMessage.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) 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 ab2c08363..2d52b5e3d 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 @@ -929,6 +929,7 @@ class ChatRepository( else -> current } + withIncompleteWarning.addAndLimit(items, scrollBackLength, ::onMessageRemoved, checkForDuplications = true) } 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 1acea0cf1..a78762a2c 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 @@ -235,7 +235,7 @@ data class ModerationMessage( 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 id = tags["id"] ?: "clearchat-$ts-$channel-${target ?: "all"}" val action = when { target == null -> Action.Clear durationSeconds == null -> Action.Ban @@ -262,7 +262,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 ?: ""}" return ModerationMessage( timestamp = ts, From 60c28d6a6478ff1b6585ce7082601ec9734b5c2f Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 15:47:00 +0100 Subject: [PATCH 081/349] refactor(chat): Split ChatItemExtensions, reduce allocations, and O(1) thread lookups --- .../dankchat/data/repo/chat/ChatRepository.kt | 4 +- .../utils/extensions/ChatListOperations.kt | 53 +++++++++++++++ ...mExtensions.kt => ModerationOperations.kt} | 64 ++----------------- .../extensions/SystemMessageOperations.kt | 27 ++++++++ 4 files changed, 88 insertions(+), 60 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt rename app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/{ChatItemExtensions.kt => ModerationOperations.kt} (63%) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt 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 2d52b5e3d..8983fab75 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 @@ -865,6 +865,7 @@ class ChatRepository( loadedRecentsInChannels += channel val recentMessages = result.messages.orEmpty() val items = mutableListOf() + val messageIndex = HashMap(recentMessages.size) val userSuggestions = mutableListOf>() measureTimeMillis { for (recentMessage in recentMessages) { @@ -895,13 +896,14 @@ class ChatRepository( val message = runCatching { Message.parse(parsedIrc, channelRepository::tryGetUserNameById) ?.applyIgnores() - ?.calculateMessageThread { _, id -> items.find { it.message.id == id }?.message } + ?.calculateMessageThread { _, id -> messageIndex[id] } ?.calculateUserDisplays() ?.parseEmotesAndBadges() ?.calculateHighlightState() ?.updateMessageInThread() }.getOrNull() ?: continue + messageIndex[message.id] = message if (message is PrivMessage) { val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() userSuggestions += message.name.lowercase() to userForSuggestion 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..61d5670c4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt @@ -0,0 +1,53 @@ +package com.flxrs.dankchat.utils.extensions + +import com.flxrs.dankchat.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/ChatItemExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt similarity index 63% rename from app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatItemExtensions.kt rename to app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt index fd7182b37..77e6e3d9b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatItemExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -4,9 +4,6 @@ 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 @@ -22,7 +19,8 @@ fun MutableList.replaceOrAddHistoryModerationMessage(moderationMessage 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) + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) + return this } val addSystemMessage = checkForStackedTimeouts(moderationMessage) @@ -52,9 +50,8 @@ fun List.replaceOrAddModerationMessage(moderationMessage: ModerationMe } } - return when { - addSystemMessage -> addAndLimit(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) - else -> this + if (addSystemMessage) { + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) } } @@ -79,59 +76,8 @@ fun List.replaceWithTimeout(moderationMessage: ModerationMessage, scro 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) - } + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) } private fun MutableList.checkForStackedTimeouts(moderationMessage: ModerationMessage): Boolean { 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..816848daf --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt @@ -0,0 +1,27 @@ +package com.flxrs.dankchat.utils.extensions + +import com.flxrs.dankchat.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 { + return 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 { + 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) + } +} From a8bf0da87fb3d09259bcb394bbbbeb56e1d8f945 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 16:42:30 +0100 Subject: [PATCH 082/349] refactor(chat): Extract MessageProcessor and RecentMessagesHandler from ChatRepository --- .../dankchat/data/repo/chat/ChatRepository.kt | 519 +++++++----------- .../data/repo/chat/MessageProcessor.kt | 86 +++ .../data/repo/chat/RecentMessagesHandler.kt | 176 ++++++ .../com/flxrs/dankchat/di/DankChatModule.kt | 2 +- 4 files changed, 460 insertions(+), 323 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/RecentMessagesHandler.kt 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 8983fab75..8fc4bd3aa 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 @@ -18,16 +18,7 @@ import com.flxrs.dankchat.data.api.eventapi.SystemMessage 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.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.repo.channel.ChannelRepository import com.flxrs.dankchat.data.repo.emote.EmoteRepository import com.flxrs.dankchat.data.toDisplayName import com.flxrs.dankchat.data.toUserId @@ -64,7 +55,6 @@ 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 @@ -95,23 +85,18 @@ 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 messageProcessor: MessageProcessor, + private val recentMessagesHandler: RecentMessagesHandler, private val emoteRepository: EmoteRepository, - private val highlightsRepository: HighlightsRepository, - private val ignoresRepository: IgnoresRepository, - private val userDisplayRepository: UserDisplayRepository, - private val repliesRepository: RepliesRepository, private val userStateRepository: UserStateRepository, private val usersRepository: UsersRepository, private val authDataStore: AuthDataStore, private val dankChatPreferenceStore: DankChatPreferenceStore, private val chatSettingsDataStore: ChatSettingsDataStore, private val pubSubManager: PubSubManager, - private val channelRepository: ChannelRepository, private val eventSubManager: EventSubManager, @Named(type = ReadConnection::class) private val readConnection: ChatConnection, @Named(type = WriteConnection::class) private val writeConnection: ChatConnection, @@ -132,7 +117,6 @@ class ChatRepository( private val _chatLoadingFailures = MutableStateFlow(emptySet()) private var lastMessage = ConcurrentHashMap() - private val loadedRecentsInChannels = mutableSetOf() private val knownRewards = ConcurrentHashMap() private val knownAutomodHeldIds: MutableSet = ConcurrentHashMap.newKeySet() private val rewardMutex = Mutex() @@ -150,178 +134,186 @@ class ChatRepository( .stateIn(scope, SharingStarted.Eagerly, 500) private val scrollBackLength get() = scrollBackLengthFlow.value + private val channelRepository get() = messageProcessor.channelRepository + 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 { collectReadConnectionEvents() } + scope.launch { collectWriteConnectionEvents() } + scope.launch { collectPubSubEvents() } + scope.launch { collectEventSubEvents() } + } + + private suspend fun collectReadConnectionEvents() { + 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 + } + + private suspend fun collectWriteConnectionEvents() { + writeConnection.messages.collect { event -> + if (event is ChatEvent.Message) { 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 - } + private suspend fun collectPubSubEvents() { + pubSubManager.messages.collect { pubSubMessage -> + when (pubSubMessage) { + is PubSubMessage.PointRedemption -> handlePubSubReward(pubSubMessage) + is PubSubMessage.Whisper -> handlePubSubWhisper(pubSubMessage) + is PubSubMessage.ModeratorAction -> handlePubSubModeration(pubSubMessage) + } + } + } - 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) - } + private suspend fun collectEventSubEvents() { + eventSubManager.events.collect { eventMessage -> + when (eventMessage) { + is ModerationAction -> handleEventSubModeration(eventMessage) + is AutomodHeld -> handleAutomodHeld(eventMessage) + is AutomodUpdate -> handleAutomodUpdate(eventMessage) + is SystemMessage -> makeAndPostSystemMessage(type = SystemMessageType.Custom(eventMessage.message)) + } + } + } - if (pubSubMessage.data.userId == userStateRepository.userState.value.userId) { - return@collect - } + private suspend fun handlePubSubReward(pubSubMessage: PubSubMessage.PointRedemption) { + if (messageProcessor.isUserBlocked(pubSubMessage.data.user.id)) { + return + } - val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() - usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) - _channelMentionCount.increment(WhisperMessage.WHISPER_CHANNEL, 1) - _notificationsFlow.tryEmit(listOf(item)) + if (pubSubMessage.data.reward.requiresUserInput) { + val id = pubSubMessage.data.reward.id + rewardMutex.withLock { + when { + knownRewards.containsKey(id) -> { + Log.d(TAG, "Removing known reward $id") + knownRewards.remove(id) } - 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) - } - } + else -> { + Log.d(TAG, "Received pubsub reward message with id $id") + knownRewards[id] = pubSubMessage } } } + } else { + val message = runCatching { + messageProcessor.processReward( + PointRedemptionMessage.parsePointReward(pubSubMessage.timestamp, pubSubMessage.data) + ) + }.getOrNull() ?: return + + messages[pubSubMessage.channelName]?.update { + it.addAndLimit(ChatItem(message), scrollBackLength, messageProcessor::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) + private suspend fun handlePubSubWhisper(pubSubMessage: PubSubMessage.Whisper) { + if (messageProcessor.isUserBlocked(pubSubMessage.data.userId)) { + return + } - else -> current.replaceOrAddModerationMessage(message, scrollBackLength, ::onMessageRemoved) - } - } - } + val message = runCatching { + messageProcessor.processWhisper(WhisperMessage.fromPubSub(pubSubMessage.data)) as? WhisperMessage + }.getOrNull() ?: return - is 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) ?: Message.DEFAULT_COLOR - 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, - ) - messages[eventMessage.channelName]?.update { current -> - current.addAndLimit(ChatItem(automodMsg, importance = ChatImportance.SYSTEM), scrollBackLength, ::onMessageRemoved) - } - } + val item = ChatItem(message, isMentionTab = true) + _whispers.update { current -> + current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved) + } - is 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 - } - updateAutomodMessageStatus(eventMessage.channelName, eventMessage.data.messageId, newStatus) - } + if (pubSubMessage.data.userId == userStateRepository.userState.value.userId) { + return + } - is SystemMessage -> makeAndPostSystemMessage(type = SystemMessageType.Custom(eventMessage.message)) - } + val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() + usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) + _channelMentionCount.increment(WhisperMessage.WHISPER_CHANNEL, 1) + _notificationsFlow.tryEmit(listOf(item)) + } + + 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 } + + applyModerationMessage(message) + } + + private fun handleEventSubModeration(eventMessage: 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 + } + + applyModerationMessage(message) + } + + private 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) } } } + 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) ?: Message.DEFAULT_COLOR + 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, + ) + messages[eventMessage.channelName]?.update { current -> + current.addAndLimit(ChatItem(automodMsg, importance = ChatImportance.SYSTEM), scrollBackLength, messageProcessor::onMessageRemoved) + } + } + + 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 + } + updateAutomodMessageStatus(eventMessage.channelName, eventMessage.data.messageId, newStatus) + } + val notificationsFlow: SharedFlow> = _notificationsFlow.asSharedFlow() val channelMentionCount: SharedFlow> = _channelMentionCount.asSharedFlow() val unreadMessagesMap: SharedFlow> = _unreadMessagesMap.asSharedFlow() @@ -345,7 +337,7 @@ class ChatRepository( chatSettingsDataStore.settings.first().loadMessageHistory -> loadRecentMessages(channel) else -> messages[channel]?.update { current -> val message = SystemMessageType.NoHistoryLoaded.toChatItem() - listOf(message).addAndLimit(current, scrollBackLength, ::onMessageRemoved, checkForDuplications = true) + listOf(message).addAndLimit(current, scrollBackLength, messageProcessor::onMessageRemoved, checkForDuplications = true) } } } @@ -357,9 +349,7 @@ class ChatRepository( messages.map { it.copy( tag = it.tag + 1, - message = it.message - .parseEmotesAndBadges() - .updateMessageInThread(), + message = messageProcessor.reparseEmotesAndBadges(it.message), ) } } @@ -445,7 +435,7 @@ class ChatRepository( ) val fakeItem = ChatItem(fakeMessage, isMentionTab = true) _whispers.update { - it.addAndLimit(fakeItem, scrollBackLength, ::onMessageRemoved) + it.addAndLimit(fakeItem, scrollBackLength, messageProcessor::onMessageRemoved) } } } @@ -553,12 +543,11 @@ class ChatRepository( connectionState.remove(channel) lastMessage.remove(channel) _channelMentionCount.clear(channel) - loadedRecentsInChannels.remove(channel) usersRepository.removeChannel(channel) userStateRepository.removeChannel(channel) channelRepository.removeRoomState(channel) emoteRepository.removeChannel(channel) - repliesRepository.cleanupMessageThreadsInChannel(channel) + messageProcessor.cleanupMessageThreadsInChannel(channel) } private fun prepareMessage(channel: UserName, message: String, replyId: String?): String? { @@ -638,7 +627,7 @@ class ChatRepository( } messages[parsed.channel]?.update { current -> - current.replaceOrAddModerationMessage(parsed, scrollBackLength, ::onMessageRemoved) + current.replaceOrAddModerationMessage(parsed, scrollBackLength, messageProcessor::onMessageRemoved) } } @@ -650,7 +639,7 @@ class ChatRepository( } messages[parsed.channel]?.update { current -> - current.replaceWithTimeout(parsed, scrollBackLength, ::onMessageRemoved) + current.replaceWithTimeout(parsed, scrollBackLength, messageProcessor::onMessageRemoved) } } @@ -660,18 +649,16 @@ class ChatRepository( } val userId = ircMessage.tags["user-id"]?.toUserId() - if (ignoresRepository.isUserBlocked(userId)) { + if (messageProcessor.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 + messageProcessor.processWhisper( + WhisperMessage.parseFromIrc(ircMessage, recipient, userState.color) + ) as? WhisperMessage }.getOrNull() ?: return val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() @@ -679,7 +666,7 @@ class ChatRepository( val item = ChatItem(message, isMentionTab = true) _whispers.update { current -> - current.addAndLimit(item, scrollBackLength, ::onMessageRemoved) + current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved) } _channelMentionCount.increment(WhisperMessage.WHISPER_CHANNEL, 1) } @@ -694,7 +681,7 @@ class ChatRepository( } val userId = ircMessage.tags["user-id"]?.toUserId() - if (ignoresRepository.isUserBlocked(userId)) { + if (messageProcessor.isUserBlocked(userId)) { return } @@ -719,7 +706,8 @@ class ChatRepository( } reward?.let { - listOf(ChatItem(PointRedemptionMessage.parsePointReward(it.timestamp, it.data).calculateHighlightState())) + val processed = messageProcessor.processReward(PointRedemptionMessage.parsePointReward(it.timestamp, it.data)) + listOfNotNull(processed?.let(::ChatItem)) }.orEmpty() } @@ -727,13 +715,9 @@ class ChatRepository( } 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() + messageProcessor.processIrcMessage(ircMessage) { channel, id -> + messages[channel]?.value?.find { it.message.id == id }?.message + } }.getOrElse { Log.e(TAG, "Failed to parse message", it) return @@ -742,7 +726,7 @@ class ChatRepository( if (message is NoticeMessage && usersRepository.isGlobalChannel(message.channel)) { messages.keys.forEach { messages[it]?.update { current -> - current.addAndLimit(ChatItem(message, importance = ChatImportance.SYSTEM), scrollBackLength, ::onMessageRemoved) + current.addAndLimit(ChatItem(message, importance = ChatImportance.SYSTEM), scrollBackLength, messageProcessor::onMessageRemoved) } } return @@ -789,7 +773,7 @@ class ChatRepository( } messages[channel]?.update { current -> - current.addAndLimit(items = additionalMessages + items, scrollBackLength, ::onMessageRemoved) + current.addAndLimit(items = additionalMessages + items, scrollBackLength, messageProcessor::onMessageRemoved) } _notificationsFlow.tryEmit(items) @@ -799,7 +783,7 @@ class ChatRepository( if (mentions.isNotEmpty()) { _mentions.update { current -> - current.addAndLimit(mentions, scrollBackLength, ::onMessageRemoved) + current.addAndLimit(mentions, scrollBackLength, messageProcessor::onMessageRemoved) } } @@ -819,13 +803,13 @@ class ChatRepository( fun makeAndPostCustomSystemMessage(message: String, channel: UserName) { messages[channel]?.update { - it.addSystemMessage(SystemMessageType.Custom(message), scrollBackLength, ::onMessageRemoved) + it.addSystemMessage(SystemMessageType.Custom(message), scrollBackLength, messageProcessor::onMessageRemoved) } } fun makeAndPostSystemMessage(type: SystemMessageType, channel: UserName) { messages[channel]?.update { - it.addSystemMessage(type, scrollBackLength, ::onMessageRemoved) + it.addSystemMessage(type, scrollBackLength, messageProcessor::onMessageRemoved) } } @@ -833,7 +817,7 @@ class ChatRepository( channels.forEach { channel -> val flow = messages[channel] ?: return@forEach val current = flow.value - flow.value = current.addSystemMessage(type, scrollBackLength, ::onMessageRemoved) { + flow.value = current.addSystemMessage(type, scrollBackLength, messageProcessor::onMessageRemoved) { scope.launch { if (chatSettingsDataStore.settings.first().loadMessageHistoryOnReconnect) { loadRecentMessages(channel, isReconnect = true) @@ -849,136 +833,28 @@ class ChatRepository( 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 - } + private suspend fun loadRecentMessages(channel: UserName, isReconnect: Boolean = false) { + val messagesFlow = messages[channel] ?: return + val result = recentMessagesHandler.load( + channel = channel, + isReconnect = isReconnect, + messagesFlow = messagesFlow, + scrollBackLength = scrollBackLength, + onMessageRemoved = messageProcessor::onMessageRemoved, + onLoadingFailure = { step, throwable -> _chatLoadingFailures.update { it + ChatLoadingFailure(step, throwable) } }, + postSystemMessage = ::makeAndPostSystemMessage, + ) - 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 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 (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 -> messageIndex[id] } - ?.calculateUserDisplays() - ?.parseEmotesAndBadges() - ?.calculateHighlightState() - ?.updateMessageInThread() - }.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 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()) - } + if (result.mentionItems.isNotEmpty()) { + _mentions.update { current -> + (current + result.mentionItems) + .distinctBy { it.message.id } + .sortedBy { it.message.timestamp } } } - 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) + usersRepository.updateUsers(channel, result.userSuggestions) } - private fun Message.updateMessageInThread(): Message = repliesRepository.updateMessageInThread(this) - - private fun onMessageRemoved(item: ChatItem) = repliesRepository.cleanupMessageThread(item.message) - private fun formatAutomodReason( reason: String, automod: AutomodReasonDto?, @@ -1018,7 +894,6 @@ class ChatRepository( 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? { + return 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? { + return message + .applyIgnores() + ?.calculateMessageThread(findMessageById) + ?.calculateUserDisplays() + ?.parseEmotesAndBadges() + ?.calculateHighlightState() + ?.updateMessageInThread() + } + + /** Partial pipeline for PubSub reward messages (no thread/emote steps). */ + suspend fun processReward(message: Message): Message? { + return message + .applyIgnores() + ?.calculateHighlightState() + ?.calculateUserDisplays() + } + + /** Partial pipeline for whisper messages (no thread step). */ + suspend fun processWhisper(message: Message): Message? { + return message + .applyIgnores() + ?.calculateHighlightState() + ?.calculateUserDisplays() + ?.parseEmotesAndBadges() + } + + /** Re-parse emotes and badges (e.g. after emote set changes). */ + suspend fun reparseEmotesAndBadges(message: Message): Message { + return 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..d5c8df871 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/RecentMessagesHandler.kt @@ -0,0 +1,176 @@ +package com.flxrs.dankchat.data.repo.chat + +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.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.toDisplayName +import com.flxrs.dankchat.data.toUserId +import com.flxrs.dankchat.data.toUserName +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.utils.extensions.addAndLimit +import com.flxrs.dankchat.utils.extensions.replaceOrAddHistoryModerationMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import kotlin.system.measureTimeMillis + +/** + * Handles loading and merging of recent message history from the RecentMessages API. + * Used for both initial channel load and reconnect. + */ +@Single +class RecentMessagesHandler( + private val recentMessagesApiClient: RecentMessagesApiClient, + private val messageProcessor: MessageProcessor, +) { + + private val loadedChannels = mutableSetOf() + + data class Result( + val mentionItems: List, + val userSuggestions: List>, + ) + + /** + * Loads recent messages for a channel, processes them, and merges into the provided message flow. + * Returns mention items and user suggestions for the caller to handle. + */ + suspend fun load( + channel: UserName, + isReconnect: Boolean = false, + messagesFlow: MutableStateFlow>, + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, + onLoadingFailure: (ChatLoadingStep, Throwable) -> Unit, + postSystemMessage: (SystemMessageType, Set) -> Unit, + ): Result = withContext(Dispatchers.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, onLoadingFailure, postSystemMessage) + } + 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 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") } + + 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, scrollBackLength, onMessageRemoved, checkForDuplications = true) + } + + val mentionItems = items.filter { it.message.highlights.hasMention() }.toMentionTabItems() + Result(mentionItems, userSuggestions) + } + + private fun handleFailure( + throwable: Throwable, + channel: UserName, + onLoadingFailure: (ChatLoadingStep, Throwable) -> Unit, + postSystemMessage: (SystemMessageType, Set) -> Unit, + ) { + val type = when (throwable) { + !is RecentMessagesApiException -> { + onLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable) + SystemMessageType.MessageHistoryUnavailable(status = null) + } + + else -> when (throwable.error) { + RecentMessagesError.ChannelNotJoined -> return + RecentMessagesError.ChannelIgnored -> SystemMessageType.MessageHistoryIgnored + else -> { + onLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable) + SystemMessageType.MessageHistoryUnavailable(status = throwable.status.value.toString()) + } + } + } + postSystemMessage(type, setOf(channel)) + } + + companion object { + private val TAG = RecentMessagesHandler::class.java.simpleName + private const val RECENT_MESSAGES_LIMIT_AFTER_RECONNECT = 100 + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt index 2d5e2ea53..a61e3e2d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt @@ -5,4 +5,4 @@ import org.koin.core.annotation.Module @Module(includes = [ConnectionModule::class, DatabaseModule::class, NetworkModule::class, CoroutineModule::class]) @ComponentScan("com.flxrs.dankchat") -class DankChatModule // dummy comment to force re-ksp +class DankChatModule // force re-ksp From 07fe6bc52cfd80069104f392f8d28fe930962b0b Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 20:44:44 +0100 Subject: [PATCH 083/349] refactor(chat): Decompose ChatRepository into focused services and fix retry snackbar --- .../com/flxrs/dankchat/DankChatViewModel.kt | 16 +- .../dankchat/auth/AuthStateCoordinator.kt | 11 +- .../chat/compose/ChatComposeViewModel.kt | 12 +- .../compose/MessageHistoryComposeViewModel.kt | 8 +- .../dankchat/chat/mention/MentionViewModel.kt | 12 +- .../compose/MentionComposeViewModel.kt | 12 +- .../compose/MessageOptionsComposeViewModel.kt | 10 +- .../data/notification/NotificationService.kt | 6 +- .../data/repo/HighlightsRepository.kt | 72 +- .../dankchat/data/repo/IgnoresRepository.kt | 29 +- .../data/repo/chat/ChatChannelProvider.kt | 25 + .../dankchat/data/repo/chat/ChatConnector.kt | 119 +++ .../data/repo/chat/ChatEventProcessor.kt | 499 ++++++++++ .../data/repo/chat/ChatMessageRepository.kt | 152 +++ .../repo/chat/ChatNotificationRepository.kt | 116 +++ .../dankchat/data/repo/chat/ChatRepository.kt | 866 ++---------------- .../data/repo/chat/MessageProcessor.kt | 2 +- .../data/repo/chat/RecentMessagesHandler.kt | 42 +- .../data/repo/emote/EmoteRepository.kt | 10 +- .../data/state/ChannelLoadingState.kt | 5 +- .../com/flxrs/dankchat/di/DankChatModule.kt | 2 +- .../dankchat/domain/ChannelDataCoordinator.kt | 83 +- .../dankchat/domain/ChannelDataLoader.kt | 4 +- .../compose/ChannelManagementViewModel.kt | 32 +- .../main/compose/ChannelPagerViewModel.kt | 18 +- .../main/compose/ChannelTabViewModel.kt | 20 +- .../main/compose/ChatInputViewModel.kt | 24 +- .../main/compose/EmoteMenuViewModel.kt | 6 +- .../main/compose/MainScreenEventHandler.kt | 18 +- .../main/compose/MainScreenViewModel.kt | 4 +- .../dankchat/main/compose/StreamViewModel.kt | 8 +- 31 files changed, 1217 insertions(+), 1026 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatChannelProvider.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatConnector.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatEventProcessor.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageRepository.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatNotificationRepository.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 69ee758bc..4abf8e2ab 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -4,8 +4,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.auth.AuthStateCoordinator -import com.flxrs.dankchat.data.repo.chat.ChatRepository +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.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -19,7 +21,9 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class DankChatViewModel( - private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatConnector: ChatConnector, + private val preferenceStore: DankChatPreferenceStore, private val authDataStore: AuthDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val dataRepository: DataRepository, @@ -29,7 +33,7 @@ class DankChatViewModel( val serviceEvents = dataRepository.serviceEvents private var initialConnectionStarted = false - val activeChannel = chatRepository.activeChannel + val activeChannel = chatChannelProvider.activeChannel val isLoggedIn: Flow = authDataStore.settings .map { it.isLoggedIn } .distinctUntilChanged() @@ -47,7 +51,9 @@ class DankChatViewModel( viewModelScope.launch { authStateCoordinator.validateOnStartup() initialConnectionStarted = true - chatRepository.connectAndJoin() + val channels = preferenceStore.channels + chatChannelProvider.setChannels(channels) + chatConnector.connectAndJoin(channels) } } @@ -55,7 +61,7 @@ class DankChatViewModel( if (!initialConnectionStarted) return viewModelScope.launch { - chatRepository.reconnectIfNecessary() + chatConnector.reconnectIfNecessary() dataRepository.reconnectIfNecessary() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt index 26f1a4013..64e3f032f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt @@ -7,7 +7,8 @@ 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.ChatRepository +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 @@ -34,7 +35,8 @@ sealed interface AuthEvent { @Single class AuthStateCoordinator( private val authDataStore: AuthDataStore, - private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatConnector: ChatConnector, private val channelDataCoordinator: ChannelDataCoordinator, private val emoteRepository: EmoteRepository, private val authApiClient: AuthApiClient, @@ -59,7 +61,7 @@ class AuthStateCoordinator( .collect { settings -> when { settings.isLoggedIn -> { - chatRepository.reconnect() + chatConnector.reconnect() channelDataCoordinator.reloadGlobalData() settings.userName?.let { name -> _events.send(AuthEvent.LoggedIn(UserName(name))) @@ -69,7 +71,8 @@ class AuthStateCoordinator( else -> { channelDataCoordinator.cancelGlobalLoading() emoteRepository.clearTwitchEmotes() - chatRepository.closeAndReconnect() + userStateRepository.clear() + chatConnector.closeAndReconnect(chatChannelProvider.channels.value.orEmpty()) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt index 3f1aec290..5c2da9305 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt @@ -9,7 +9,7 @@ import com.flxrs.dankchat.chat.ChatItem 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.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository import com.flxrs.dankchat.data.twitch.message.SystemMessageType import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettings @@ -44,7 +44,7 @@ import java.util.Locale @KoinViewModel class ChatComposeViewModel( @InjectedParam private val channel: UserName, - private val repository: ChatRepository, + private val chatMessageRepository: ChatMessageRepository, private val chatMessageMapper: ChatMessageMapper, private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore, @@ -64,7 +64,7 @@ class ChatComposeViewModel( ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) - private val chat: StateFlow> = repository + private val chat: StateFlow> = chatMessageRepository .getChat(channel) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) @@ -146,9 +146,9 @@ class ChatComposeViewModel( .onFailure { error -> Log.e(TAG, "Failed to $action automod message $heldMessageId", error) val statusCode = (error as? HelixApiException)?.status?.value - repository.makeAndPostSystemMessage( - SystemMessageType.AutomodActionFailed(statusCode = statusCode, allow = allow), - channel + chatMessageRepository.addSystemMessage( + channel, + SystemMessageType.AutomodActionFailed(statusCode = statusCode, allow = allow) ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt index a3f951280..612bf5bf9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt @@ -16,7 +16,7 @@ import com.flxrs.dankchat.chat.search.SearchFilterSuggestions import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.chat.ChatRepository +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.preferences.DankChatPreferenceStore @@ -42,7 +42,7 @@ import org.koin.core.annotation.InjectedParam @KoinViewModel class MessageHistoryComposeViewModel( @InjectedParam private val channel: UserName, - chatRepository: ChatRepository, + chatMessageRepository: ChatMessageRepository, usersRepository: UsersRepository, private val chatMessageMapper: ChatMessageMapper, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, @@ -74,7 +74,7 @@ class MessageHistoryComposeViewModel( .distinctUntilChanged() val historyUiStates: Flow> = combine( - chatRepository.getChat(channel), + chatMessageRepository.getChat(channel), filters, appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, @@ -95,7 +95,7 @@ class MessageHistoryComposeViewModel( private val users: StateFlow> = usersRepository.getUsersFlow(channel) - private val badgeNames: StateFlow> = chatRepository.getChat(channel) + private val badgeNames: StateFlow> = chatMessageRepository.getChat(channel) .map { items -> items.asSequence() .map { it.message } 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 index 790ee27bf..77d737203 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt @@ -3,7 +3,7 @@ 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 com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed @@ -12,15 +12,15 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class MentionViewModel(chatRepository: ChatRepository) : ViewModel() { +class MentionViewModel(chatNotificationRepository: ChatNotificationRepository) : ViewModel() { - val mentions: StateFlow> = chatRepository.mentions + val mentions: StateFlow> = chatNotificationRepository.mentions .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - val whispers: StateFlow> = chatRepository.whispers + val whispers: StateFlow> = chatNotificationRepository.whispers .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - val hasMentions: StateFlow = chatRepository.hasMentions + val hasMentions: StateFlow = chatNotificationRepository.hasMentions .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - val hasWhispers: StateFlow = chatRepository.hasWhispers + val hasWhispers: StateFlow = chatNotificationRepository.hasWhispers .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt index 43acac72e..b28ad2604 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt @@ -6,7 +6,7 @@ import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.chat.compose.ChatDisplaySettings import com.flxrs.dankchat.chat.compose.ChatMessageMapper import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore @@ -23,7 +23,7 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class MentionComposeViewModel( - chatRepository: ChatRepository, + chatNotificationRepository: ChatNotificationRepository, private val chatMessageMapper: ChatMessageMapper, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, @@ -48,9 +48,9 @@ class MentionComposeViewModel( _currentTab.value = index } - val mentions: StateFlow> = chatRepository.mentions + val mentions: StateFlow> = chatNotificationRepository.mentions .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), emptyList()) - val whispers: StateFlow> = chatRepository.whispers + val whispers: StateFlow> = chatNotificationRepository.whispers .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), emptyList()) val mentionsUiStates: Flow> = combine( @@ -87,8 +87,8 @@ class MentionComposeViewModel( } }.flowOn(Dispatchers.Default) - val hasMentions: StateFlow = chatRepository.hasMentions + val hasMentions: StateFlow = chatNotificationRepository.hasMentions .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) - val hasWhispers: StateFlow = chatRepository.hasWhispers + val hasWhispers: StateFlow = chatNotificationRepository.hasWhispers .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt index bb3c18520..5b3ed568e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt @@ -5,6 +5,9 @@ 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 @@ -28,14 +31,17 @@ class MessageOptionsComposeViewModel( @InjectedParam private val canModerateParam: Boolean, @InjectedParam private val canReplyParam: Boolean, private val chatRepository: ChatRepository, + private val chatMessageRepository: ChatMessageRepository, + private val chatConnector: ChatConnector, + private val chatNotificationRepository: ChatNotificationRepository, private val channelRepository: ChannelRepository, private val userStateRepository: UserStateRepository, private val commandRepository: CommandRepository, private val repliesRepository: RepliesRepository, ) : ViewModel() { - private val messageFlow = flowOf(chatRepository.findMessage(messageId, channel)) - private val connectionStateFlow = chatRepository.getConnectionState(channel ?: WhisperMessage.WHISPER_CHANNEL) + 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, 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 f7b933eb3..8de2124ee 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 @@ -16,7 +16,7 @@ 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.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 @@ -54,7 +54,7 @@ class NotificationService : Service(), CoroutineScope { private val notifications = mutableMapOf>() private val notifiedMessageIds = LinkedHashSet() - private val chatRepository: ChatRepository by inject() + private val chatNotificationRepository: ChatNotificationRepository by inject() private val dataRepository: DataRepository by inject() private val toolsSettingsDataStore: ToolsSettingsDataStore by inject() private val notificationsSettingsDataStore: NotificationsSettingsDataStore by inject() @@ -215,7 +215,7 @@ class NotificationService : Service(), CoroutineScope { notificationsJob?.cancel() notificationsJob = launch { - chatRepository.notificationsFlow.collect { items -> + chatNotificationRepository.notificationsFlow.collect { items -> items.forEach { (message) -> if (shouldNotifyOnMention && notificationsEnabled) { if (!notifiedMessageIds.add(message.id)) { 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 5a4c6f9b6..3989f4c6c 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 @@ -206,12 +206,12 @@ class HighlightsRepository( val messageHighlights = validMessageHighlights.value val highlights = buildSet { - val subsHighlight = messageHighlights.subsHighlight + val subsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Subscription) if (isSub && subsHighlight != null) { add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) } - val announcementsHighlight = messageHighlights.announcementsHighlight + val announcementsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Announcement) if (isAnnouncement && announcementsHighlight != null) { add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) } @@ -224,9 +224,9 @@ class HighlightsRepository( } 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()) } @@ -245,44 +245,44 @@ class HighlightsRepository( val badgeHighlights = validBadgeHighlights.value val messageHighlights = validMessageHighlights.value val highlights = buildSet { - val subsHighlight = messageHighlights.subsHighlight + val subsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Subscription) if (isSub && subsHighlight != null) { add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) } - val announcementsHighlight = messageHighlights.announcementsHighlight + val announcementsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Announcement) if (isAnnouncement && announcementsHighlight != null) { add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) } - val rewardsHighlight = messageHighlights.rewardsHighlight + val rewardsHighlight = messageHighlights.ofType(MessageHighlightEntityType.ChannelPointRedemption) if (isReward && rewardsHighlight != null) { add(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor)) } - val firstMessageHighlight = messageHighlights.firstMessageHighlight + val firstMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.FirstMessage) if (isFirstMessage && firstMessageHighlight != null) { add(Highlight(HighlightType.FirstMessage, firstMessageHighlight.customColor)) } - val elevatedMessageHighlight = messageHighlights.elevatedMessageHighlight + val elevatedMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.ElevatedMessage) if (isElevatedMessage && elevatedMessageHighlight != null) { add(Highlight(HighlightType.ElevatedMessage, elevatedMessageHighlight.customColor)) } if (containsCurrentUserName) { - val highlight = messageHighlights.userNameHighlight + val highlight = messageHighlights.ofType(MessageHighlightEntityType.Username) if (highlight?.enabled == true) { add(Highlight(HighlightType.Username, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight) + addNotificationHighlightIfEnabled(highlight.createNotification) } } if (containsParticipatedReply) { - val highlight = messageHighlights.repliesHighlight + val highlight = messageHighlights.ofType(MessageHighlightEntityType.Reply) if (highlight?.enabled == true) { add(Highlight(HighlightType.Reply, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight) + addNotificationHighlightIfEnabled(highlight.createNotification) } } @@ -293,14 +293,14 @@ class HighlightsRepository( if (message.contains(regex)) { add(Highlight(HighlightType.Custom, it.customColor)) - addNotificationHighlightIfEnabled(it) + addNotificationHighlightIfEnabled(it.createNotification) } } userHighlights.forEach { if (name.matches(it.username)) { add(Highlight(HighlightType.Custom, it.customColor)) - addNotificationHighlightIfEnabled(it) + addNotificationHighlightIfEnabled(it.createNotification) } } badgeHighlights.forEach { highlight -> @@ -314,7 +314,7 @@ class HighlightsRepository( } if (match) { add(Highlight(HighlightType.Badge, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight) + addNotificationHighlightIfEnabled(highlight.createNotification) } } } @@ -329,41 +329,11 @@ class HighlightsRepository( else -> this } - private val List.subsHighlight: MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.Subscription } + private fun List.ofType(type: MessageHighlightEntityType): MessageHighlightEntity? = + find { it.type == type } - 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)) - } - } - - private fun MutableCollection.addNotificationHighlightIfEnabled(highlightEntity: UserHighlightEntity) { - if (highlightEntity.createNotification) { - add(Highlight(HighlightType.Notification)) - } - } - - private fun MutableCollection.addNotificationHighlightIfEnabled(highlightEntity: BadgeHighlightEntity) { - if (highlightEntity.createNotification) { + private fun MutableCollection.addNotificationHighlightIfEnabled(createNotification: Boolean) { + if (createNotification) { add(Highlight(HighlightType.Notification)) } } 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..723cd4559 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 @@ -203,11 +203,11 @@ 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.areAnnouncementsIgnored) { + if (isAnnouncement && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.Announcement)) { return null } @@ -219,23 +219,23 @@ class IgnoresRepository( 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 } @@ -287,21 +287,6 @@ class IgnoresRepository( 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 } } 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..36c13ba26 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatChannelProvider.kt @@ -0,0 +1,25 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.data.UserName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.annotation.Single + +@Single +class ChatChannelProvider { + + private val _activeChannel = MutableStateFlow(null) + private val _channels = MutableStateFlow?>(null) + + val activeChannel: StateFlow = _activeChannel.asStateFlow() + val channels: StateFlow?> = _channels.asStateFlow() + + fun setActiveChannel(channel: UserName?) { + _activeChannel.value = channel + } + + fun setChannels(channels: List) { + _channels.value = channels + } +} 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..31fc1c59f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatConnector.kt @@ -0,0 +1,119 @@ +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.ReadConnection +import com.flxrs.dankchat.di.WriteConnection +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(type = ReadConnection::class) private val readConnection: ChatConnection, + @Named(type = WriteConnection::class) 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 setConnectionState(channel: UserName, state: ConnectionState) { + connectionState[channel]?.value = state + } + + fun setAllConnectionStates(state: ConnectionState) { + connectionState.keys.forEach { + connectionState[it]?.value = state + } + } + + fun createConnectionState(channel: UserName) { + connectionState.putIfAbsent(channel, MutableStateFlow(ConnectionState.DISCONNECTED)) + } + + fun removeConnectionState(channel: UserName) { + connectionState.remove(channel) + } + + fun connectAndJoin(channels: List) { + if (!pubSubManager.connected) { + pubSubManager.start() + } + + if (!readConnection.connected) { + readConnection.connect() + writeConnection.connect() + + if (channels.isNotEmpty()) { + readConnection.joinChannels(channels) + } + } + } + + fun closeAndReconnect(channels: List) = scope.launch { + readConnection.close() + writeConnection.close() + pubSubManager.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 addPubSubChannel(channel: UserName) { + pubSubManager.addChannel(channel) + } + + fun partChannel(channel: UserName) { + readConnection.partChannel(channel) + pubSubManager.removeChannel(channel) + eventSubManager.removeChannel(channel) + } + + fun sendRaw(message: String) { + writeConnection.sendMessage(message) + } + + val connectedAndHasWhisperTopic: Boolean get() = pubSubManager.connectedAndHasWhisperTopic + + fun connectedAndHasModerateTopic(channel: UserName): Boolean = eventSubManager.connectedAndHasModerateTopic(channel) +} 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..7ec86d359 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatEventProcessor.kt @@ -0,0 +1,499 @@ +package com.flxrs.dankchat.data.repo.chat + +import android.graphics.Color +import android.util.Log +import com.flxrs.dankchat.R +import com.flxrs.dankchat.auth.AuthDataStore +import com.flxrs.dankchat.chat.ChatImportance +import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.chat.compose.TextResource +import com.flxrs.dankchat.chat.toMentionTabItems +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.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.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.pubsub.PubSubMessage +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.extensions.withoutInvisibleChar +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 + +@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.channel, 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.Whisper -> handlePubSubWhisper(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 SystemMessage -> postSystemMessageAndReconnect(type = SystemMessageType.Custom(eventMessage.message)) + } + } + } + + private suspend fun handlePubSubReward(pubSubMessage: PubSubMessage.PointRedemption) { + if (messageProcessor.isUserBlocked(pubSubMessage.data.user.id)) { + return + } + + if (pubSubMessage.data.reward.requiresUserInput) { + val id = pubSubMessage.data.reward.id + rewardMutex.withLock { + when { + 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 { + messageProcessor.processReward( + PointRedemptionMessage.parsePointReward(pubSubMessage.timestamp, pubSubMessage.data) + ) + }.getOrNull() ?: return + + chatMessageRepository.addMessages(pubSubMessage.channelName, listOf(ChatItem(message))) + } + } + + private suspend fun handlePubSubWhisper(pubSubMessage: PubSubMessage.Whisper) { + if (messageProcessor.isUserBlocked(pubSubMessage.data.userId)) { + return + } + + val message = runCatching { + messageProcessor.processWhisper(WhisperMessage.fromPubSub(pubSubMessage.data)) as? WhisperMessage + }.getOrNull() ?: return + + val item = ChatItem(message, isMentionTab = true) + chatNotificationRepository.addWhisper(item) + + if (pubSubMessage.data.userId == userStateRepository.userState.value.userId) { + return + } + + val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() + usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) + chatNotificationRepository.incrementMentionCount(WhisperMessage.WHISPER_CHANNEL, 1) + chatNotificationRepository.emitNotification(listOf(item)) + } + + 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 { + Log.d(TAG, "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) ?: Message.DEFAULT_COLOR + 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 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(channel: UserName, isAnonymous: Boolean) { + val state = when { + isAnonymous -> ConnectionState.CONNECTED_NOT_LOGGED_IN + else -> ConnectionState.CONNECTED + } + postSystemMessageAndReconnect(state.toSystemMessageType(), setOf(channel)) + chatConnector.setConnectionState(channel, state) + } + + 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) { + if (chatConnector.connectedAndHasWhisperTopic) { + return + } + + 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 item = ChatItem(message, isMentionTab = true) + chatNotificationRepository.addWhisper(item) + chatNotificationRepository.incrementMentionCount(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 (chatConnector.connectedAndHasModerateTopic(channel)) { + return + } + } + + if (messageProcessor.isUserBlocked(ircMessage.tags["user-id"]?.toUserId())) { + return + } + + val additionalMessages = resolveRewardMessages(ircMessage) + + val message = runCatching { + messageProcessor.processIrcMessage(ircMessage) { channel, id -> + chatMessageRepository.findMessage(id, channel, chatNotificationRepository.whispers) + } + }.getOrElse { + Log.e(TAG, "Failed to parse message", it) + return + } ?: 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.emitNotification(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 resolveRewardMessages(ircMessage: IrcMessage): List { + val rewardId = ircMessage.tags["custom-reward-id"]?.takeIf { it.isNotEmpty() } ?: return emptyList() + val isAutomodApproval = knownAutomodHeldIds.remove(rewardId) + if (isAutomodApproval) { + return emptyList() + } + + 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) { + chatConnector.pubSubEvents + .filterIsInstance() + .first { it.data.reward.id == rewardId } + }?.also { knownRewards[rewardId] = it } + } + } + + return reward?.let { + val processed = messageProcessor.processReward(PointRedemptionMessage.parsePointReward(it.timestamp, it.data)) + listOfNotNull(processed?.let(::ChatItem)) + }.orEmpty() + } + + private fun trackUserState(message: Message) { + if (message !is PrivMessage) { + return + } + + if (message.color != Message.DEFAULT_COLOR) { + usersRepository.cacheUserColor(message.name, message.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 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 val TAG = ChatEventProcessor::class.java.simpleName + private const val PUBSUB_TIMEOUT = 5000L + } +} 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..a308baf19 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageRepository.kt @@ -0,0 +1,152 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.data.UserName +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.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.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.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +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 + +@Single +class ChatMessageRepository( + private val messageProcessor: MessageProcessor, + chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, +) { + + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private val messages = ConcurrentHashMap>>() + private val _chatLoadingFailures = MutableStateFlow(emptySet()) + + 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()) } + + fun getMessagesFlow(channel: UserName): MutableStateFlow>? = messages[channel] + + 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) { + 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(Dispatchers.Default) { + messages.values.map { flow -> + async { + flow.update { items -> + items.map { + it.copy( + tag = it.tag + 1, + message = messageProcessor.reparseEmotesAndBadges(it.message), + ) + } + } + } + }.awaitAll() + } + + 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/ChatNotificationRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatNotificationRepository.kt new file mode 100644 index 000000000..4c27ceff4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatNotificationRepository.kt @@ -0,0 +1,116 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.data.UserName +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.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>(emptyList()) + private val _whispers = MutableStateFlow>(emptyList()) + private val _notificationsFlow = 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 notificationsFlow: SharedFlow> = _notificationsFlow.asSharedFlow() + val channelMentionCount: SharedFlow> = _channelMentionCount.asSharedFlow() + val unreadMessagesMap: SharedFlow> = _unreadMessagesMap.asSharedFlow() + val hasMentions = channelMentionCount.map { it.any { (key, value) -> key != WhisperMessage.WHISPER_CHANNEL && value > 0 } } + val hasWhispers = channelMentionCount.map { it.getOrDefault(WhisperMessage.WHISPER_CHANNEL, 0) > 0 } + val mentions: StateFlow> = _mentions + val whispers: StateFlow> = _whispers + + fun addMentions(items: List) { + if (items.isEmpty()) return + _mentions.update { current -> + current.addAndLimit(items, scrollBackLength, messageProcessor::onMessageRemoved) + } + } + + fun addMentionsDeduped(items: List) { + if (items.isEmpty()) return + _mentions.update { current -> + (current + items) + .distinctBy { it.message.id } + .sortedBy { it.message.timestamp } + } + } + + fun addWhisper(item: ChatItem) { + _whispers.update { current -> + current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved) + } + } + + fun emitNotification(items: List) { + _notificationsFlow.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 8fc4bd3aa..594877437 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,417 +1,93 @@ package com.flxrs.dankchat.data.repo.chat import android.graphics.Color -import android.util.Log -import com.flxrs.dankchat.R import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.chat.ChatImportance import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.compose.TextResource -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.AutomodHeld -import com.flxrs.dankchat.data.api.eventapi.AutomodUpdate -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.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.irc.IrcMessage +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.badge.Badge -import com.flxrs.dankchat.data.twitch.badge.BadgeType -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.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.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.replaceOrAddModerationMessage -import com.flxrs.dankchat.utils.extensions.replaceWithTimeout -import com.flxrs.dankchat.utils.extensions.withoutInvisibleChar -import kotlinx.collections.immutable.persistentListOf -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 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 @Single class ChatRepository( - private val messageProcessor: MessageProcessor, - private val recentMessagesHandler: RecentMessagesHandler, - private val emoteRepository: EmoteRepository, + 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 emoteRepository: EmoteRepository, + private val channelRepository: ChannelRepository, + private val messageProcessor: MessageProcessor, private val authDataStore: AuthDataStore, - private val dankChatPreferenceStore: DankChatPreferenceStore, private val chatSettingsDataStore: ChatSettingsDataStore, - private val pubSubManager: PubSubManager, - private val eventSubManager: EventSubManager, - @Named(type = ReadConnection::class) private val readConnection: ChatConnection, - @Named(type = WriteConnection::class) private val writeConnection: ChatConnection, - dispatchersProvider: DispatchersProvider, ) { - 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 knownRewards = ConcurrentHashMap() - private val knownAutomodHeldIds: MutableSet = ConcurrentHashMap.newKeySet() - 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 - - private val channelRepository get() = messageProcessor.channelRepository + val activeChannel get() = chatChannelProvider.activeChannel + val channels get() = chatChannelProvider.channels - init { - scope.launch { collectReadConnectionEvents() } - scope.launch { collectWriteConnectionEvents() } - scope.launch { collectPubSubEvents() } - scope.launch { collectEventSubEvents() } - } + fun setActiveChannel(channel: UserName?) = chatChannelProvider.setActiveChannel(channel) - private suspend fun collectReadConnectionEvents() { - 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() - } - } - } - - private suspend fun collectWriteConnectionEvents() { - writeConnection.messages.collect { event -> - if (event is ChatEvent.Message) { - onWriterMessage(event.message) - } - } - } - - private suspend fun collectPubSubEvents() { - pubSubManager.messages.collect { pubSubMessage -> - when (pubSubMessage) { - is PubSubMessage.PointRedemption -> handlePubSubReward(pubSubMessage) - is PubSubMessage.Whisper -> handlePubSubWhisper(pubSubMessage) - is PubSubMessage.ModeratorAction -> handlePubSubModeration(pubSubMessage) - } - } - } - - private suspend fun collectEventSubEvents() { - eventSubManager.events.collect { eventMessage -> - when (eventMessage) { - is ModerationAction -> handleEventSubModeration(eventMessage) - is AutomodHeld -> handleAutomodHeld(eventMessage) - is AutomodUpdate -> handleAutomodUpdate(eventMessage) - is SystemMessage -> makeAndPostSystemMessage(type = SystemMessageType.Custom(eventMessage.message)) - } - } - } - - private suspend fun handlePubSubReward(pubSubMessage: PubSubMessage.PointRedemption) { - if (messageProcessor.isUserBlocked(pubSubMessage.data.user.id)) { - return - } - - if (pubSubMessage.data.reward.requiresUserInput) { - val id = pubSubMessage.data.reward.id - rewardMutex.withLock { - when { - 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 { - messageProcessor.processReward( - PointRedemptionMessage.parsePointReward(pubSubMessage.timestamp, pubSubMessage.data) - ) - }.getOrNull() ?: return - - messages[pubSubMessage.channelName]?.update { - it.addAndLimit(ChatItem(message), scrollBackLength, messageProcessor::onMessageRemoved) - } - } - } - - private suspend fun handlePubSubWhisper(pubSubMessage: PubSubMessage.Whisper) { - if (messageProcessor.isUserBlocked(pubSubMessage.data.userId)) { - return - } - - val message = runCatching { - messageProcessor.processWhisper(WhisperMessage.fromPubSub(pubSubMessage.data)) as? WhisperMessage - }.getOrNull() ?: return - - val item = ChatItem(message, isMentionTab = true) - _whispers.update { current -> - current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved) - } - - if (pubSubMessage.data.userId == userStateRepository.userState.value.userId) { - return - } - - val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() - usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) - _channelMentionCount.increment(WhisperMessage.WHISPER_CHANNEL, 1) - _notificationsFlow.tryEmit(listOf(item)) - } - - 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 } - - applyModerationMessage(message) - } - - private fun handleEventSubModeration(eventMessage: 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 - } - - applyModerationMessage(message) - } - - private 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) - } - } - } - - 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) ?: Message.DEFAULT_COLOR - 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, - ) - messages[eventMessage.channelName]?.update { current -> - current.addAndLimit(ChatItem(automodMsg, importance = ChatImportance.SYSTEM), scrollBackLength, messageProcessor::onMessageRemoved) + fun joinChannel(channel: UserName, listenToPubSub: Boolean = true): List { + val currentChannels = channels.value.orEmpty() + if (channel in currentChannels) { + return currentChannels } - } - 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 - } - updateAutomodMessageStatus(eventMessage.channelName, eventMessage.data.messageId, newStatus) - } + val updatedChannels = currentChannels + channel + chatChannelProvider.setChannels(updatedChannels) - 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() } + createFlowsIfNecessary(channel) + chatMessageRepository.clearMessages(channel) - 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, messageProcessor::onMessageRemoved, checkForDuplications = true) - } + chatConnector.joinIrcChannel(channel) + if (listenToPubSub) { + chatConnector.addPubSubChannel(channel) } - } - - suspend fun reparseAllEmotesAndBadges() = withContext(Dispatchers.Default) { - messages.values.map { flow -> - async { - flow.update { messages -> - messages.map { - it.copy( - tag = it.tag + 1, - message = messageProcessor.reparseEmotesAndBadges(it.message), - ) - } - } - } - }.awaitAll() - } - - fun setActiveChannel(channel: UserName?) { - _activeChannel.value = channel - } - - 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) + 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() + fun sendMessage(input: String, replyId: String? = null) { + val channel = chatChannelProvider.activeChannel.value ?: return + val preparedMessage = prepareMessage(channel, input, replyId) ?: return + chatConnector.sendRaw(preparedMessage) } - fun getLastMessage(): String? = activeChannel.value?.let { lastMessage[it]?.withoutInvisibleChar } - fun fakeWhisperIfNecessary(input: String) { - if (pubSubManager.connectedAndHasWhisperTopic) { + if (chatConnector.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) @@ -434,115 +110,39 @@ class ChatRepository( emotes = emotes, ) val fakeItem = ChatItem(fakeMessage, isMentionTab = true) - _whispers.update { - it.addAndLimit(fakeItem, scrollBackLength, messageProcessor::onMessageRemoved) - } - } - } - - 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 updateChannels(updatedChannels: List): List { - val currentChannels = channels.value.orEmpty() - val removedChannels = currentChannels - updatedChannels.toSet() - - removedChannels.forEach { - partChannel(it) + chatNotificationRepository.addWhisper(fakeItem) } - - _channels.value = updatedChannels - return removedChannels } - fun appendLastMessage(channel: UserName, message: String) { - lastMessage[channel] = message - } - - private fun connect() { - readConnection.connect() - writeConnection.connect() - } + fun getLastMessage(): String? = chatEventProcessor.getLastMessageForDisplay(chatChannelProvider.activeChannel.value) - private fun joinChannels(channels: List) { - _channels.value = channels - if (channels.isEmpty()) return + fun appendLastMessage(channel: UserName, message: String) = chatEventProcessor.setLastMessage(channel, message) - channels.onEach { - createFlowsIfNecessary(it) - if (messages[it]?.value == null) { - messages[it]?.value = emptyList() + suspend fun loadRecentMessagesIfEnabled(channel: UserName) { + when { + chatSettingsDataStore.settings.first().loadMessageHistory -> chatEventProcessor.loadRecentMessages(channel) + else -> { + chatMessageRepository.getMessagesFlow(channel)?.update { current -> + current + SystemMessageType.NoHistoryLoaded.toChatItem() + } } } - - 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) + fun makeAndPostSystemMessage(type: SystemMessageType, channel: UserName) { + chatMessageRepository.addSystemMessage(channel, type) + } - return updatedChannels + fun makeAndPostCustomSystemMessage(msg: String, channel: UserName) { + chatMessageRepository.addSystemMessage(channel, SystemMessageType.Custom(msg)) } - private fun removeChannelData(channel: UserName) { - messages.remove(channel) - connectionState.remove(channel) - lastMessage.remove(channel) - _channelMentionCount.clear(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) @@ -551,13 +151,15 @@ class ChatRepository( } private fun prepareMessage(channel: UserName, message: String, replyId: String?): String? { - if (message.isBlank()) return null + if (message.isBlank()) { + return null + } + val trimmedMessage = message.trimEnd() val replyIdOrBlank = replyId?.let { "@reply-parent-msg-id=$it " }.orEmpty() + val currentLastMessage = chatEventProcessor.getLastMessage(channel).orEmpty() - val messageWithSuffix = if (lastMessage[channel].orEmpty() == trimmedMessage) { - // Find first space to double (preferred — Twitch strips extra spaces server-side) - // Skip the first space if message starts with / or . (Twitch command prefix) + val messageWithSuffix = if (currentLastMessage == trimmedMessage) { val startIndex = if (trimmedMessage.startsWith('/') || trimmedMessage.startsWith('.')) { trimmedMessage.indexOf(' ').let { if (it == -1) 0 else it + 1 } } else { @@ -565,337 +167,15 @@ class ChatRepository( } val spaceIndex = trimmedMessage.indexOf(' ', startIndex) - if (spaceIndex != -1) { - // Double the space — invisible to viewers, different on the wire - trimmedMessage.replaceRange(spaceIndex, spaceIndex + 1, " ") - } else { - // No space to double, fall back to invisible char suffix - "$trimmedMessage $INVISIBLE_CHAR" + when { + spaceIndex != -1 -> trimmedMessage.replaceRange(spaceIndex, spaceIndex + 1, " ") + else -> "$trimmedMessage $INVISIBLE_CHAR" } } else { trimmedMessage } - lastMessage[channel] = messageWithSuffix + chatEventProcessor.setLastMessage(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, messageProcessor::onMessageRemoved) - } - } - - private fun handleClearMsg(msg: IrcMessage) { - val parsed = runCatching { - ModerationMessage.parseClearMessage(msg) - }.getOrElse { - return - } - - messages[parsed.channel]?.update { current -> - current.replaceWithTimeout(parsed, scrollBackLength, messageProcessor::onMessageRemoved) - } - } - - private suspend fun handleWhisper(ircMessage: IrcMessage) { - if (pubSubManager.connectedAndHasWhisperTopic) { - return - } - - 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 item = ChatItem(message, isMentionTab = true) - _whispers.update { current -> - current.addAndLimit(item, scrollBackLength, messageProcessor::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 (messageProcessor.isUserBlocked(userId)) { - return - } - - val rewardId = ircMessage.tags["custom-reward-id"]?.takeIf { it.isNotEmpty() } - val isAutomodApproval = rewardId != null && knownAutomodHeldIds.remove(rewardId) - val additionalMessages = when { - rewardId != null && !isAutomodApproval -> { - 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 { - val processed = messageProcessor.processReward(PointRedemptionMessage.parsePointReward(it.timestamp, it.data)) - listOfNotNull(processed?.let(::ChatItem)) - }.orEmpty() - } - - else -> emptyList() - } - - val message = runCatching { - messageProcessor.processIrcMessage(ircMessage) { channel, id -> - messages[channel]?.value?.find { it.message.id == id }?.message - } - }.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, messageProcessor::onMessageRemoved) - } - } - return - } - - if (message is PrivMessage) { - if (message.color != Message.DEFAULT_COLOR) { - usersRepository.cacheUserColor(message.name, message.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) - } - - 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, messageProcessor::onMessageRemoved) - } - - _notificationsFlow.tryEmit(items) - val mentions = items - .filter { it.message.highlights.hasMention() } - .toMentionTabItems() - - if (mentions.isNotEmpty()) { - _mentions.update { current -> - current.addAndLimit(mentions, scrollBackLength, messageProcessor::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, messageProcessor::onMessageRemoved) - } - } - - fun makeAndPostSystemMessage(type: SystemMessageType, channel: UserName) { - messages[channel]?.update { - it.addSystemMessage(type, scrollBackLength, messageProcessor::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, messageProcessor::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) { - val messagesFlow = messages[channel] ?: return - val result = recentMessagesHandler.load( - channel = channel, - isReconnect = isReconnect, - messagesFlow = messagesFlow, - scrollBackLength = scrollBackLength, - onMessageRemoved = messageProcessor::onMessageRemoved, - onLoadingFailure = { step, throwable -> _chatLoadingFailures.update { it + ChatLoadingFailure(step, throwable) } }, - postSystemMessage = ::makeAndPostSystemMessage, - ) - - if (result.mentionItems.isNotEmpty()) { - _mentions.update { current -> - (current + result.mentionItems) - .distinctBy { it.message.id } - .sortedBy { it.message.timestamp } - } - } - usersRepository.updateUsers(channel, result.userSuggestions) - } - - 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) - } - - 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 - } - } - } - } - - companion object { - private val TAG = ChatRepository::class.java.simpleName - private val ESCAPE_TAG = 0x000E0002.codePointAsString - - private const val PUBSUB_TIMEOUT = 5000L - - val ESCAPE_TAG_REGEX = "(?() @@ -48,19 +42,7 @@ class RecentMessagesHandler( val userSuggestions: List>, ) - /** - * Loads recent messages for a channel, processes them, and merges into the provided message flow. - * Returns mention items and user suggestions for the caller to handle. - */ - suspend fun load( - channel: UserName, - isReconnect: Boolean = false, - messagesFlow: MutableStateFlow>, - scrollBackLength: Int, - onMessageRemoved: (ChatItem) -> Unit, - onLoadingFailure: (ChatLoadingStep, Throwable) -> Unit, - postSystemMessage: (SystemMessageType, Set) -> Unit, - ): Result = withContext(Dispatchers.IO) { + suspend fun load(channel: UserName, isReconnect: Boolean = false): Result = withContext(Dispatchers.IO) { if (!isReconnect && channel in loadedChannels) { return@withContext Result(emptyList(), emptyList()) } @@ -68,7 +50,7 @@ class RecentMessagesHandler( val limit = if (isReconnect) RECENT_MESSAGES_LIMIT_AFTER_RECONNECT else null val result = recentMessagesApiClient.getRecentMessages(channel, limit).getOrElse { throwable -> if (!isReconnect) { - handleFailure(throwable, channel, onLoadingFailure, postSystemMessage) + handleFailure(throwable, channel) } return@withContext Result(emptyList(), emptyList()) } @@ -129,7 +111,8 @@ class RecentMessagesHandler( } }.let { Log.i(TAG, "Parsing message history for #$channel took $it ms") } - messagesFlow.update { current -> + 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() @@ -138,22 +121,17 @@ class RecentMessagesHandler( else -> current } - withIncompleteWarning.addAndLimit(items, scrollBackLength, onMessageRemoved, checkForDuplications = true) + 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, - onLoadingFailure: (ChatLoadingStep, Throwable) -> Unit, - postSystemMessage: (SystemMessageType, Set) -> Unit, - ) { + private fun handleFailure(throwable: Throwable, channel: UserName) { val type = when (throwable) { !is RecentMessagesApiException -> { - onLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable) + chatMessageRepository.addLoadingFailure(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable)) SystemMessageType.MessageHistoryUnavailable(status = null) } @@ -161,12 +139,12 @@ class RecentMessagesHandler( RecentMessagesError.ChannelNotJoined -> return RecentMessagesError.ChannelIgnored -> SystemMessageType.MessageHistoryIgnored else -> { - onLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable) + chatMessageRepository.addLoadingFailure(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable)) SystemMessageType.MessageHistoryUnavailable(status = throwable.status.value.toString()) } } } - postSystemMessage(type, setOf(channel)) + chatMessageRepository.addSystemMessageToChannels(type, setOf(channel)) } companion object { 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 4d9f2130c..a465e2f13 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 @@ -29,7 +29,7 @@ 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.utils.extensions.codePointAsString import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.twitch.badge.Badge import com.flxrs.dankchat.data.twitch.badge.BadgeSet @@ -151,8 +151,8 @@ class EmoteRepository( val (messageString, channel, emotesWithPositions) = emoteData val withEmojiFix = messageString.replace( - ChatRepository.ESCAPE_TAG_REGEX, - ChatRepository.ZERO_WIDTH_JOINER + ESCAPE_TAG_REGEX, + ZERO_WIDTH_JOINER ) // Combined single-pass: find supplementary codepoint positions AND remove duplicate whitespace @@ -862,6 +862,10 @@ class EmoteRepository( companion object { private val TAG = EmoteRepository::class.java.simpleName + + private val ESCAPE_TAG = 0x000E0002.codePointAsString + val ESCAPE_TAG_REGEX = "(?.cacheKey(baseHeight: Int): String = joinToString(separator = "-") { it.id } + "-$baseHeight" 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 index f5be03922..1dbdc0cc4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -2,6 +2,8 @@ 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 @@ -55,6 +57,7 @@ sealed interface GlobalLoadingState { data object Loaded : GlobalLoadingState data class Failed( val message: String, - val failures: Set = emptySet() + val failures: Set = emptySet(), + val chatFailures: Set = emptySet(), ) : GlobalLoadingState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt index a61e3e2d9..89537dde1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt @@ -5,4 +5,4 @@ import org.koin.core.annotation.Module @Module(includes = [ConnectionModule::class, DatabaseModule::class, NetworkModule::class, CoroutineModule::class]) @ComponentScan("com.flxrs.dankchat") -class DankChatModule // force re-ksp +class DankChatModule // ksp regen v3 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 09a6119d5..975e54b26 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -3,10 +3,8 @@ package com.flxrs.dankchat.domain import android.util.Log import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName -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.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 @@ -25,6 +23,7 @@ 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 @@ -33,9 +32,8 @@ import java.util.concurrent.ConcurrentHashMap class ChannelDataCoordinator( private val channelDataLoader: ChannelDataLoader, private val globalDataLoader: GlobalDataLoader, - private val chatRepository: ChatRepository, + private val chatMessageRepository: ChatMessageRepository, private val dataRepository: DataRepository, - private val userStateRepository: UserStateRepository, private val authDataStore: AuthDataStore, private val preferenceStore: DankChatPreferenceStore, dispatchersProvider: DispatchersProvider @@ -56,26 +54,41 @@ class ChannelDataCoordinator( dataRepository.dataUpdateEvents.collect { event -> when (event) { is DataUpdateEventMessage.ActiveEmoteSetChanged -> { - chatRepository.makeAndPostSystemMessage( - type = SystemMessageType.ChannelSevenTVEmoteSetChanged(event.actorName, event.emoteSetName), - channel = event.channel - ) + chatMessageRepository.addSystemMessage(event.channel, SystemMessageType.ChannelSevenTVEmoteSetChanged(event.actorName, event.emoteSetName)) } is DataUpdateEventMessage.EmoteSetUpdated -> { val (channel, update) = event - update.added.forEach { chatRepository.makeAndPostSystemMessage(SystemMessageType.ChannelSevenTVEmoteAdded(update.actorName, it.name), channel) } - update.updated.forEach { chatRepository.makeAndPostSystemMessage(SystemMessageType.ChannelSevenTVEmoteRenamed(update.actorName, it.oldName, it.name), channel) } - update.removed.forEach { chatRepository.makeAndPostSystemMessage(SystemMessageType.ChannelSevenTVEmoteRemoved(update.actorName, it.name), channel) } + 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 -> { + val total = chatFailures.size + GlobalLoadingState.Failed( + message = "$total data source(s) failed to load", + chatFailures = chatFailures, + ) + } + + else -> current + } } } } } } - /** - * Get loading state for a specific channel - */ fun getChannelLoadingState(channel: UserName): StateFlow { return channelStates.getOrPut(channel) { MutableStateFlow(ChannelLoadingState.Idle) @@ -98,7 +111,7 @@ class ChannelDataCoordinator( // Reparse immediately with whatever emotes are available now // Don't wait for globalLoadJob — channel 3rd party emotes should show immediately - chatRepository.reparseAllEmotesAndBadges() + chatMessageRepository.reparseAllEmotesAndBadges() } } @@ -113,7 +126,7 @@ class ChannelDataCoordinator( globalDataLoader.loadGlobalData() // Reparse after global emotes load so 3rd party globals are visible immediately - chatRepository.reparseAllEmotesAndBadges() + chatMessageRepository.reparseAllEmotesAndBadges() // Load user emotes if logged in — only block on first page, rest loads async if (authDataStore.isLoggedIn) { @@ -123,23 +136,26 @@ class ChannelDataCoordinator( launch { try { globalDataLoader.loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } - chatRepository.reparseAllEmotesAndBadges() + chatMessageRepository.reparseAllEmotesAndBadges() } catch (e: Exception) { Log.e(TAG, "Failed to load user emotes", e) firstPageLoaded.complete(Unit) } } firstPageLoaded.await() - chatRepository.reparseAllEmotesAndBadges() + chatMessageRepository.reparseAllEmotesAndBadges() } } - val failures = dataRepository.dataLoadingFailures.value + val dataFailures = dataRepository.dataLoadingFailures.value + val chatFailures = chatMessageRepository.chatLoadingFailures.value + val totalFailures = dataFailures.size + chatFailures.size _globalLoadingState.value = when { - failures.isEmpty() -> GlobalLoadingState.Loaded + totalFailures == 0 -> GlobalLoadingState.Loaded else -> GlobalLoadingState.Failed( - message = "${failures.size} provider(s) failed to load", - failures = failures + message = "$totalFailures data source(s) failed to load", + failures = dataFailures, + chatFailures = chatFailures, ) } } @@ -181,14 +197,15 @@ class ChannelDataCoordinator( /** * Retry specific failed data and chat steps */ - fun retryDataLoading(dataFailures: Set, chatFailures: Set) { + fun retryDataLoading(failedState: GlobalLoadingState.Failed) { scope.launch { _globalLoadingState.value = GlobalLoadingState.Loading + dataRepository.clearDataLoadingFailures() + chatMessageRepository.clearChatLoadingFailures() - // Collect channels that need retry val channelsToRetry = mutableSetOf() - val dataResults = dataFailures.map { failure -> + val dataResults = failedState.failures.map { failure -> async { when (val step = failure.step) { is DataLoadingStep.GlobalSevenTVEmotes -> globalDataLoader.loadGlobalSevenTVEmotes() @@ -205,7 +222,7 @@ class ChannelDataCoordinator( } } - chatFailures.forEach { failure -> + failedState.chatFailures.forEach { failure -> when (val step = failure.step) { is ChatLoadingStep.RecentMessages -> channelsToRetry.add(step.channel) } @@ -214,7 +231,17 @@ class ChannelDataCoordinator( dataResults.awaitAll() channelsToRetry.forEach { loadChannelData(it) } - _globalLoadingState.value = GlobalLoadingState.Loaded + val remainingDataFailures = dataRepository.dataLoadingFailures.value + val remainingChatFailures = chatMessageRepository.chatLoadingFailures.value + val totalRemaining = remainingDataFailures.size + remainingChatFailures.size + _globalLoadingState.value = when { + totalRemaining == 0 -> GlobalLoadingState.Loaded + else -> GlobalLoadingState.Failed( + message = "$totalRemaining data source(s) failed to load", + 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 index b930882ad..b6f17c5d8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -5,6 +5,7 @@ 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 @@ -21,6 +22,7 @@ import org.koin.core.annotation.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 @@ -72,7 +74,7 @@ class ChannelDataLoader( else -> null } systemMessageType?.let { - chatRepository.makeAndPostSystemMessage(it, channel) + chatMessageRepository.addSystemMessage(channel, it) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt index 366f7d226..d61fe7d2c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt @@ -5,6 +5,10 @@ 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.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 @@ -20,6 +24,10 @@ class ChannelManagementViewModel( private val preferenceStore: DankChatPreferenceStore, private val channelDataCoordinator: ChannelDataCoordinator, private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatConnector: ChatConnector, + private val chatMessageRepository: ChatMessageRepository, + private val chatNotificationRepository: ChatNotificationRepository, private val ignoresRepository: IgnoresRepository, private val channelRepository: ChannelRepository, ) : ViewModel() { @@ -31,10 +39,10 @@ class ChannelManagementViewModel( init { // Set initial active channel if not already set viewModelScope.launch { - if (chatRepository.activeChannel.value == null) { + if (chatChannelProvider.activeChannel.value == null) { val firstChannel = preferenceStore.channels.firstOrNull() if (firstChannel != null) { - chatRepository.setActiveChannel(firstChannel) + chatChannelProvider.setActiveChannel(firstChannel) } } } @@ -60,18 +68,18 @@ class ChannelManagementViewModel( if (channel !in current) { preferenceStore.channels = current + channel chatRepository.joinChannel(channel) - chatRepository.setActiveChannel(channel) + chatChannelProvider.setActiveChannel(channel) } } fun removeChannel(channel: UserName) { - val wasActive = chatRepository.activeChannel.value == channel + val wasActive = chatChannelProvider.activeChannel.value == channel preferenceStore.removeChannel(channel) chatRepository.updateChannels(preferenceStore.channels) channelDataCoordinator.cleanupChannel(channel) if (wasActive) { - chatRepository.setActiveChannel(preferenceStore.channels.firstOrNull()) + chatChannelProvider.setActiveChannel(preferenceStore.channels.firstOrNull()) } } @@ -93,11 +101,11 @@ class ChannelManagementViewModel( } fun reconnect() { - chatRepository.reconnect() + chatConnector.reconnect() } fun clearChat(channel: UserName) { - chatRepository.clear(channel) + chatMessageRepository.clearMessages(channel) } fun blockChannel(channel: UserName) = viewModelScope.launch { @@ -113,9 +121,9 @@ class ChannelManagementViewModel( } fun selectChannel(channel: UserName) { - chatRepository.setActiveChannel(channel) - chatRepository.clearUnreadMessage(channel) - chatRepository.clearMentionCount(channel) + chatChannelProvider.setActiveChannel(channel) + chatNotificationRepository.clearUnreadMessage(channel) + chatNotificationRepository.clearMentionCount(channel) } fun applyChanges(updatedChannels: List) { @@ -133,12 +141,12 @@ class ChannelManagementViewModel( } // 2. Update active channel if removed - val activeChannel = chatRepository.activeChannel.value + 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() - chatRepository.setActiveChannel(newActive) + chatChannelProvider.setActiveChannel(newActive) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt index 5870db8f5..f75e84a93 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt @@ -4,7 +4,9 @@ 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.ChatRepository +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 @@ -17,13 +19,15 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class ChannelPagerViewModel( - private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatMessageRepository: ChatMessageRepository, + private val chatNotificationRepository: ChatNotificationRepository, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { val uiState: StateFlow = combine( preferenceStore.getChannelsWithRenamesFlow(), - chatRepository.activeChannel, + chatChannelProvider.activeChannel, ) { channels, active -> ChannelPagerUiState( channels = channels.map { it.channel }.toImmutableList(), @@ -36,9 +40,9 @@ class ChannelPagerViewModel( val channels = preferenceStore.channels if (page in channels.indices) { val channel = channels[page] - chatRepository.setActiveChannel(channel) - chatRepository.clearUnreadMessage(channel) - chatRepository.clearMentionCount(channel) + chatChannelProvider.setActiveChannel(channel) + chatNotificationRepository.clearUnreadMessage(channel) + chatNotificationRepository.clearMentionCount(channel) } } @@ -50,7 +54,7 @@ class ChannelPagerViewModel( val channels = preferenceStore.channels val index = channels.indexOfFirst { it == channel } if (index < 0) return null - if (chatRepository.getChat(channel).value.none { it.message.id == messageId }) return null + if (chatMessageRepository.getChat(channel).value.none { it.message.id == messageId }) return null onPageChanged(index) return JumpTarget(channelIndex = index, channel = channel, messageId = messageId) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt index 885a7de16..27118767a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt @@ -4,7 +4,8 @@ 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.ChatRepository +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.domain.ChannelDataCoordinator @@ -22,7 +23,8 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class ChannelTabViewModel( - private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatNotificationRepository: ChatNotificationRepository, private val channelDataCoordinator: ChannelDataCoordinator, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { @@ -38,9 +40,9 @@ class ChannelTabViewModel( } combine( - chatRepository.activeChannel, - chatRepository.unreadMessagesMap, - chatRepository.channelMentionCount, + chatChannelProvider.activeChannel, + chatNotificationRepository.unreadMessagesMap, + chatNotificationRepository.channelMentionCount, combine(loadingFlows) { it.toList() }, channelDataCoordinator.globalLoadingState ) { active, unread, mentions, loadingStates, globalState -> @@ -70,14 +72,14 @@ class ChannelTabViewModel( val channels = preferenceStore.channels if (index in channels.indices) { val channel = channels[index] - chatRepository.setActiveChannel(channel) - chatRepository.clearUnreadMessage(channel) - chatRepository.clearMentionCount(channel) + chatChannelProvider.setActiveChannel(channel) + chatNotificationRepository.clearUnreadMessage(channel) + chatNotificationRepository.clearMentionCount(channel) } } fun clearAllMentionCounts() { - chatRepository.clearMentionCounts() + chatNotificationRepository.clearMentionCounts() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index e25a7ac8c..600aed84e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -14,6 +14,8 @@ import com.flxrs.dankchat.chat.suggestion.SuggestionProvider 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 @@ -57,6 +59,8 @@ import org.koin.android.annotation.KoinViewModel @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, @@ -105,7 +109,7 @@ class ChatInputViewModel( // Get suggestions based on current text, cursor position, and active channel private val suggestions: StateFlow> = combine( debouncedTextAndCursor, - chatRepository.activeChannel + chatChannelProvider.activeChannel ) { (text, cursorPos), channel -> Triple(text, cursorPos, channel) }.flatMapLatest { (text, cursorPos, channel) -> @@ -114,7 +118,7 @@ class ChatInputViewModel( private val roomStateDisplayText: StateFlow = combine( chatSettingsDataStore.showChatModes, - chatRepository.activeChannel + chatChannelProvider.activeChannel ) { showModes, channel -> showModes to channel }.flatMapLatest { (showModes, channel) -> @@ -125,7 +129,7 @@ class ChatInputViewModel( private val currentStreamInfo: StateFlow = combine( streamsSettingsDataStore.showStreamsInfo, - chatRepository.activeChannel, + chatChannelProvider.activeChannel, streamDataRepository.streamData ) { streamInfoEnabled, activeChannel, streamData -> streamData.find { it.channel == activeChannel }?.formattedData?.takeIf { streamInfoEnabled } @@ -146,7 +150,7 @@ class ChatInputViewModel( init { viewModelScope.launch { - chatRepository.activeChannel.collect { + chatChannelProvider.activeChannel.collect { repeatedSend.update { it.copy(enabled = false) } } } @@ -168,7 +172,7 @@ class ChatInputViewModel( repeatedSend.collectLatest { if (it.enabled && it.message.isNotBlank()) { while (isActive) { - val activeChannel = chatRepository.activeChannel.value ?: break + val activeChannel = chatChannelProvider.activeChannel.value ?: break val delay = userStateRepository.getSendDelay(activeChannel) trySendMessageOrCommand(it.message, skipSuspendingCommands = true) delay(delay) @@ -214,10 +218,10 @@ class ChatInputViewModel( val baseFlow = combine( textFlow, suggestions, - chatRepository.activeChannel, - chatRepository.activeChannel.flatMapLatest { channel -> + chatChannelProvider.activeChannel, + chatChannelProvider.activeChannel.flatMapLatest { channel -> if (channel == null) flowOf(ConnectionState.DISCONNECTED) - else chatRepository.getConnectionState(channel) + else chatConnector.getConnectionState(channel) }, combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b } ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, autoDisableInput) -> @@ -319,7 +323,7 @@ class ChatInputViewModel( } fun trySendMessageOrCommand(message: String, skipSuspendingCommands: Boolean = false) = viewModelScope.launch { - val channel = chatRepository.activeChannel.value ?: return@launch + val channel = chatChannelProvider.activeChannel.value ?: return@launch val chatState = fullScreenSheetState.value val replyIdOrNull = when { chatState is FullScreenSheetState.Replies -> chatState.replyMessageId @@ -429,7 +433,7 @@ class ChatInputViewModel( } fun postSystemMessage(message: String) { - val channel = chatRepository.activeChannel.value ?: return + val channel = chatChannelProvider.activeChannel.value ?: return chatRepository.makeAndPostCustomSystemMessage(message, channel) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt index beb19e181..5e526b3ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTabItem -import com.flxrs.dankchat.data.repo.chat.ChatRepository +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 @@ -27,12 +27,12 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class EmoteMenuViewModel( - private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, private val dataRepository: DataRepository, private val emoteUsageRepository: EmoteUsageRepository, ) : ViewModel() { - private val activeChannel = chatRepository.activeChannel + private val activeChannel = chatChannelProvider.activeChannel private val emotes = activeChannel .flatMapLatestOrDefault(Emotes()) { dataRepository.getEmotes(it) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index 99ec8c990..46179df1e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -110,18 +110,16 @@ fun MainScreenEventHandler( } } - // Handle data loading errors val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() LaunchedEffect(loadingState) { - val state = loadingState as? GlobalLoadingState.Failed - if (state != null) { - launch { - snackbarHostState.showSnackbar( - message = state.message, - actionLabel = resources.getString(R.string.snackbar_retry), - duration = SnackbarDuration.Long - ) - } + val state = loadingState as? GlobalLoadingState.Failed ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + message = state.message, + actionLabel = resources.getString(R.string.snackbar_retry), + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + mainScreenViewModel.retryDataLoading(state) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt index 779b2ac63..04d1aeded 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt @@ -144,8 +144,8 @@ class MainScreenViewModel( _isFullscreen.update { !it } } - fun retryDataLoading(dataFailures: Set, chatFailures: Set) { - channelDataCoordinator.retryDataLoading(dataFailures, chatFailures) + fun retryDataLoading(failedState: GlobalLoadingState.Failed) { + channelDataCoordinator.retryDataLoading(failedState) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt index 81426108d..f47220bad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt @@ -6,7 +6,7 @@ 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.ChatRepository +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider import com.flxrs.dankchat.data.repo.stream.StreamDataRepository import com.flxrs.dankchat.main.stream.StreamWebView import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore @@ -23,7 +23,7 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class StreamViewModel( application: Application, - private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, private val streamDataRepository: StreamDataRepository, private val streamsSettingsDataStore: StreamsSettingsDataStore, ) : AndroidViewModel(application) { @@ -31,7 +31,7 @@ class StreamViewModel( private val _currentStreamedChannel = MutableStateFlow(null) private val hasStreamData: StateFlow = combine( - chatRepository.activeChannel, + chatChannelProvider.activeChannel, streamDataRepository.streamData ) { activeChannel, streamData -> activeChannel != null && streamData.any { it.channel == activeChannel } @@ -54,7 +54,7 @@ class StreamViewModel( init { viewModelScope.launch { - chatRepository.channels.collect { channels -> + chatChannelProvider.channels.collect { channels -> if (channels != null) { streamDataRepository.fetchStreamData(channels) } From 8590235b43ffcdd66ef3f2e814585b2c0e4d4d26 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 20:51:15 +0100 Subject: [PATCH 084/349] refactor(domain): Remove useless KDoc from GlobalDataLoader --- .../com/flxrs/dankchat/domain/GlobalDataLoader.kt | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 353a21e1f..fd482cbd9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -20,10 +20,6 @@ class GlobalDataLoader( private val dispatchersProvider: DispatchersProvider ) { - /** - * Load all global data (badges, emotes, commands, blocks) - * Returns the list of Results from each emote/badge provider. - */ suspend fun loadGlobalData(): List> = withContext(dispatchersProvider.io) { val results = awaitAll( async { loadDankChatBadges() }, @@ -32,7 +28,6 @@ class GlobalDataLoader( async { loadGlobalFFZEmotes() }, async { loadGlobalSevenTVEmotes() }, ) - // Fire-and-forget tasks that handle their own errors launch { loadSupibotCommands() } launch { loadUserBlocks() } results @@ -46,20 +41,14 @@ class GlobalDataLoader( suspend fun loadSupibotCommands() = commandRepository.loadSupibotCommands() suspend fun loadUserBlocks() = ignoresRepository.loadUserBlocks() - /** - * Load user-specific global emotes via Helix API (requires login + user:read:emotes scope) - */ suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) { dataRepository.loadUserEmotes(userId, onFirstPageLoaded) } - /** - * Load user-specific global emotes via DankChat API (legacy fallback) - */ suspend fun loadUserStateEmotes( globalEmoteSets: List, followerEmoteSets: Map> ) { dataRepository.loadUserStateEmotes(globalEmoteSets, followerEmoteSets) } -} \ No newline at end of file +} From 583808fd577518a37d242f85713a672befc4adab Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 20:51:58 +0100 Subject: [PATCH 085/349] chore: Add serena project configuration --- .serena/.gitignore | 2 + .serena/project.yml | 152 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000..2e510aff5 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000..e02062ed7 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,152 @@ +# the name by which the project can be referenced within Serena +project_name: "dankchat" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- kotlin + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] From 7323079f69752c08f09ce94bd6d560c03b4a16d3 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 21:36:18 +0100 Subject: [PATCH 086/349] fix(compose): Use translated string resources for retry snackbar and remove dead code --- .../data/state/ChannelLoadingState.kt | 2 -- .../dankchat/domain/ChannelDataCoordinator.kt | 20 +++++-------------- .../dankchat/domain/ChannelDataLoader.kt | 6 +++--- .../flxrs/dankchat/main/compose/MainScreen.kt | 3 --- .../main/compose/MainScreenEventHandler.kt | 12 ++++++++--- 5 files changed, 17 insertions(+), 26 deletions(-) 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 index 1dbdc0cc4..4f4de27b1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -10,7 +10,6 @@ sealed interface ChannelLoadingState { data object Loading : ChannelLoadingState data object Loaded : ChannelLoadingState data class Failed( - val message: String, val failures: List ) : ChannelLoadingState } @@ -56,7 +55,6 @@ sealed interface GlobalLoadingState { data object Loading : GlobalLoadingState data object Loaded : GlobalLoadingState data class Failed( - val message: String, val failures: Set = emptySet(), val chatFailures: Set = emptySet(), ) : GlobalLoadingState diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 975e54b26..e2a6833a0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -73,13 +73,7 @@ class ChannelDataCoordinator( _globalLoadingState.update { current -> when (current) { is GlobalLoadingState.Failed -> current.copy(chatFailures = chatFailures) - is GlobalLoadingState.Loaded -> { - val total = chatFailures.size - GlobalLoadingState.Failed( - message = "$total data source(s) failed to load", - chatFailures = chatFailures, - ) - } + is GlobalLoadingState.Loaded -> GlobalLoadingState.Failed(chatFailures = chatFailures) else -> current } @@ -149,11 +143,9 @@ class ChannelDataCoordinator( val dataFailures = dataRepository.dataLoadingFailures.value val chatFailures = chatMessageRepository.chatLoadingFailures.value - val totalFailures = dataFailures.size + chatFailures.size _globalLoadingState.value = when { - totalFailures == 0 -> GlobalLoadingState.Loaded - else -> GlobalLoadingState.Failed( - message = "$totalFailures data source(s) failed to load", + dataFailures.isEmpty() && chatFailures.isEmpty() -> GlobalLoadingState.Loaded + else -> GlobalLoadingState.Failed( failures = dataFailures, chatFailures = chatFailures, ) @@ -233,11 +225,9 @@ class ChannelDataCoordinator( val remainingDataFailures = dataRepository.dataLoadingFailures.value val remainingChatFailures = chatMessageRepository.chatLoadingFailures.value - val totalRemaining = remainingDataFailures.size + remainingChatFailures.size _globalLoadingState.value = when { - totalRemaining == 0 -> GlobalLoadingState.Loaded - else -> GlobalLoadingState.Failed( - message = "$totalRemaining data source(s) failed to load", + 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 index b6f17c5d8..f7e2b62ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -41,7 +41,7 @@ class ChannelDataLoader( val channelInfo = channelRepository.getChannel(channel) ?: getChannelsUseCase(listOf(channel)).firstOrNull() if (channelInfo == null) { - emit(ChannelLoadingState.Failed("Channel not found", emptyList())) + emit(ChannelLoadingState.Failed(emptyList())) return@flow } @@ -80,10 +80,10 @@ class ChannelDataLoader( when { failures.isEmpty() -> emit(ChannelLoadingState.Loaded) - else -> emit(ChannelLoadingState.Failed("Some data failed to load", failures)) + else -> emit(ChannelLoadingState.Failed(failures)) } } catch (e: Exception) { - emit(ChannelLoadingState.Failed(e.message ?: "Unknown error", emptyList())) + emit(ChannelLoadingState.Failed(emptyList())) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 4ecaebe28..9c36e3800 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -83,7 +83,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -143,7 +142,6 @@ fun MainScreen( onChooseMedia: () -> Unit, modifier: Modifier = Modifier ) { - val resources = LocalResources.current val context = LocalContext.current val density = LocalDensity.current val messageNotInHistoryMsg = stringResource(R.string.message_not_in_history) @@ -271,7 +269,6 @@ fun MainScreen( val inputSheetState = sheetNavState.inputSheet MainScreenEventHandler( - resources = resources, snackbarHostState = snackbarHostState, mainEventBus = mainEventBus, dialogViewModel = dialogViewModel, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index 46179df1e..c104f78da 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.main.compose import android.content.ClipData import android.content.ClipboardManager -import android.content.res.Resources import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult @@ -10,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.core.content.getSystemService import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R @@ -25,7 +25,6 @@ import org.koin.compose.koinInject @Composable fun MainScreenEventHandler( - resources: Resources, snackbarHostState: SnackbarHostState, mainEventBus: MainEventBus, dialogViewModel: DialogStateViewModel, @@ -35,6 +34,7 @@ fun MainScreenEventHandler( preferenceStore: DankChatPreferenceStore, ) { val context = LocalContext.current + val resources = LocalResources.current val authStateCoordinator: AuthStateCoordinator = koinInject() // MainEventBus event collection @@ -113,8 +113,14 @@ fun MainScreenEventHandler( val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() LaunchedEffect(loadingState) { val state = loadingState as? GlobalLoadingState.Failed ?: return@LaunchedEffect + val failedSteps = state.failures.map { it.step } + state.chatFailures.map { it.step } + val stepsText = failedSteps.joinToString(", ") { it::class.simpleName.orEmpty() } + val message = when { + failedSteps.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 = state.message, + message = message, actionLabel = resources.getString(R.string.snackbar_retry), duration = SnackbarDuration.Long ) From 1be88c999ac2d56c9481762ff4c4375ff9f88f70 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 21:44:43 +0100 Subject: [PATCH 087/349] fix(compose): Hide input actions in mention/whisper sheets and use AddComment icon --- .../com/flxrs/dankchat/main/compose/ChatInputLayout.kt | 3 ++- .../kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 6ecca75bb..61b017b32 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -30,6 +30,7 @@ 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.Clear import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle @@ -471,7 +472,7 @@ fun ChatInputLayout( modifier = Modifier.size(iconSize) ) { Icon( - imageVector = Icons.Default.Edit, + imageVector = Icons.Default.AddComment, contentDescription = stringResource(R.string.whisper_new), ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 9c36e3800..9a8eba723 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -115,6 +115,7 @@ import com.flxrs.dankchat.tour.FeatureTourViewModel import com.flxrs.dankchat.tour.PostOnboardingStep import com.flxrs.dankchat.tour.TourStep import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -564,7 +565,11 @@ fun MainScreen( isStreamActive = currentStream != null, hasStreamData = hasStreamData, isSheetOpen = isSheetOpen, - inputActions = mainState.inputActions, + inputActions = when (fullScreenSheetState) { + is FullScreenSheetState.Mention, + is FullScreenSheetState.Whisper -> persistentListOf() + else -> mainState.inputActions + }, characterCounter = if (mainState.showCharacterCounter) inputState.characterCounter else CharacterCounterState.Hidden, onSend = chatInputViewModel::sendMessage, onLastMessageClick = chatInputViewModel::getLastMessage, From 88ca6853e90bdb04052a8f5155ba11e6037cfd15 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 24 Mar 2026 23:23:49 +0100 Subject: [PATCH 088/349] fix(compose): Improve sheet toolbars with scroll hiding, z-order fix, and input actions --- .../chat/mention/compose/MentionComposable.kt | 2 + .../chat/replies/compose/RepliesComposable.kt | 2 + .../dankchat/main/compose/ChatInputLayout.kt | 28 ++-- .../main/compose/ChatInputViewModel.kt | 21 ++- .../flxrs/dankchat/main/compose/MainScreen.kt | 87 ++++++----- .../main/compose/sheets/MentionSheet.kt | 144 +++++++++++------- .../compose/sheets/MessageHistorySheet.kt | 121 +++++++++------ .../main/compose/sheets/RepliesSheet.kt | 115 +++++++++----- 8 files changed, 319 insertions(+), 201 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index 6788b04a6..3f95355d7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -36,6 +36,7 @@ fun MentionComposable( onWhisperReply: ((userName: UserName) -> Unit)? = null, containerColor: Color, contentPadding: PaddingValues = PaddingValues(), + scrollModifier: Modifier = Modifier, ) { val displaySettings by mentionViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val messages by when { @@ -59,6 +60,7 @@ fun MentionComposable( onEmoteClick = onEmoteClick, onWhisperReply = if (isWhisperTab) onWhisperReply else null, contentPadding = contentPadding, + scrollModifier = scrollModifier, containerColor = containerColor, ) } // CompositionLocalProvider diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index 7d0783806..e383c4a17 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -35,6 +35,7 @@ fun RepliesComposable( containerColor: Color, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), + scrollModifier: Modifier = Modifier, ) { val displaySettings by repliesViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) @@ -55,6 +56,7 @@ fun RepliesComposable( onMessageLongClick = onMessageLongClick, onEmoteClick = { /* no-op for replies */ }, contentPadding = contentPadding, + scrollModifier = scrollModifier, containerColor = containerColor, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 61b017b32..101b79dc1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -447,7 +447,20 @@ fun ChatInputLayout( } } - // Configurable action icons (only those that fit) + // 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 for (action in visibleActions) { InputActionButton( action = action, @@ -465,19 +478,6 @@ fun ChatInputLayout( ) } - // 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), - ) - } - } - // Send Button (Right) SendButton( enabled = canSend, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 600aed84e..1ebcd7774 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -90,6 +90,7 @@ class ChatInputViewModel( val isEmoteMenuOpen = _isEmoteMenuOpen.asStateFlow() private val _whisperTarget = MutableStateFlow(null) + private var lastWhisperText: String? = null val whisperTarget: StateFlow = _whisperTarget.asStateFlow() // Create flow from TextFieldState tracking both text and cursor position @@ -286,7 +287,10 @@ class ChatInputViewModel( text = text, canSend = canSend, enabled = enabled, - hasLastMessage = chatRepository.getLastMessage() != null, + hasLastMessage = when { + isWhisperTabActive -> lastWhisperText != null + else -> chatRepository.getLastMessage() != null + }, suggestions = suggestions.toImmutableList(), activeChannel = activeChannel, connectionState = connectionState, @@ -312,11 +316,11 @@ class ChatInputViewModel( val text = textFieldState.text.toString() if (text.isNotBlank()) { val whisperTarget = _whisperTarget.value - val messageToSend = if (whisperTarget != null) { - "/w ${whisperTarget.value} $text" - } else { - text + val messageToSend = when { + whisperTarget != null -> "/w ${whisperTarget.value} $text" + else -> text } + lastWhisperText = if (whisperTarget != null) text else null trySendMessageOrCommand(messageToSend) textFieldState.clearText() } @@ -378,9 +382,12 @@ class ChatInputViewModel( } fun getLastMessage() { - val lastMessage = chatRepository.getLastMessage() ?: return + val message = when { + _whisperTarget.value != null -> lastWhisperText + else -> chatRepository.getLastMessage() + } ?: return textFieldState.edit { - replace(0, length, lastMessage) + replace(0, length, message) placeCursorAtEnd() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 9a8eba723..2f0edfe63 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -115,6 +115,7 @@ import com.flxrs.dankchat.tour.FeatureTourViewModel import com.flxrs.dankchat.tour.PostOnboardingStep import com.flxrs.dankchat.tour.TourStep import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding +import com.flxrs.dankchat.preferences.appearance.InputAction import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce @@ -566,9 +567,15 @@ fun MainScreen( hasStreamData = hasStreamData, isSheetOpen = isSheetOpen, inputActions = when (fullScreenSheetState) { - is FullScreenSheetState.Mention, - is FullScreenSheetState.Whisper -> persistentListOf() - else -> mainState.inputActions + is FullScreenSheetState.Replies -> persistentListOf(InputAction.LastMessage) + is FullScreenSheetState.Whisper, + is FullScreenSheetState.Mention -> when { + inputState.isWhisperTabActive && inputState.whisperTarget != null -> persistentListOf(InputAction.LastMessage) + else -> persistentListOf() + } + + is FullScreenSheetState.History, + is FullScreenSheetState.Closed -> mainState.inputActions }, characterCounter = if (mainState.showCharacterCounter) inputState.characterCounter else CharacterCounterState.Hidden, onSend = chatInputViewModel::sendMessage, @@ -1085,43 +1092,6 @@ fun MainScreen( } } - // Fullscreen Overlay Sheets - above stream layer so they're not hidden - if (!isInPipMode) { - fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) - } - - // Dismiss scrim for input overflow menu - if (!isInPipMode && inputOverflowExpanded) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - if (!featureTourState.forceOverflowOpen) { - inputOverflowExpanded = false - } - } - ) - } - - // Input bar - rendered after sheet overlay so it's on top - if (!isInPipMode) { - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ) - ) { - bottomBar() - } - } - // Status bar scrim when stream is active — fades with stream/toolbar if (currentStream != null && !isFullscreen && !isInPipMode) { Box( @@ -1153,6 +1123,43 @@ fun MainScreen( ) } + // Fullscreen Overlay Sheets — after toolbar/scrims so sheets render on top + if (!isInPipMode) { + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + } + + // Input bar — on top of sheets for whisper/reply input + if (!isInPipMode) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ) + ) { + bottomBar() + } + } + + // Dismiss scrim for input overflow menu + if (!isInPipMode && inputOverflowExpanded) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (!featureTourState.forceOverflowOpen) { + inputOverflowExpanded = false + } + } + ) + } + // Emote Menu Layer - slides up/down independently of keyboard // Fast tween to match system keyboard animation speed if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 4c06c8266..4252464c5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -1,6 +1,11 @@ package com.flxrs.dankchat.main.compose.sheets 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.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,6 +15,7 @@ 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.height import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -26,6 +32,7 @@ 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 @@ -34,12 +41,14 @@ 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.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker import com.flxrs.dankchat.chat.mention.compose.MentionComposable import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel import com.flxrs.dankchat.data.UserName @@ -65,9 +74,9 @@ fun MentionSheet( pageCount = { 2 } ) var backProgress by remember { mutableFloatStateOf(0f) } + var toolbarVisible by remember { mutableStateOf(true) } val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - // Toolbar area: status bar + padding + pill height + padding val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp val sheetBackgroundColor = lerp( MaterialTheme.colorScheme.surfaceContainer, @@ -75,6 +84,16 @@ fun MentionSheet( 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) + LaunchedEffect(pagerState.currentPage) { mentionViewModel.setCurrentTab(pagerState.currentPage) } @@ -102,7 +121,6 @@ fun MentionSheet( translationY = backProgress * 100f } ) { - // Chat content - edge to edge HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), @@ -116,73 +134,87 @@ fun MentionSheet( onWhisperReply = if (page == 1) onWhisperReply else null, containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), + scrollModifier = scrollModifier, ) } - // Floating toolbar with gradient scrim - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .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), + AnimatedVisibility( + visible = toolbarVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = Modifier.align(Alignment.TopCenter), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top + 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), ) { - // Back navigation pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } } - } - // Tab pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - 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, - ) + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + 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 (!toolbarVisible) { + 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/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt index 53e7da0f7..863a0dee0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -1,6 +1,11 @@ package com.flxrs.dankchat.main.compose.sheets 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.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,6 +14,7 @@ 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.height import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars @@ -36,6 +42,7 @@ 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 @@ -44,6 +51,7 @@ 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.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -54,6 +62,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.LocalPlatformContext import coil3.imageLoader import com.flxrs.dankchat.R +import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatScreen import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator @@ -115,6 +124,17 @@ fun MessageHistorySheet( } } + 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) + val context = LocalPlatformContext.current val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) @@ -141,65 +161,80 @@ fun MessageHistorySheet( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), + scrollModifier = scrollModifier, containerColor = sheetBackgroundColor, ) } - // Floating toolbar with gradient scrim - back pill + channel name pill - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .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), + AnimatedVisibility( + visible = toolbarVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = Modifier.align(Alignment.TopCenter), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top, + 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), ) { - // Back navigation pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - ) + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } } - } - // Channel name pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 16.dp), + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, ) { - Text( - text = stringResource(R.string.message_history_title, channel.value), - style = MaterialTheme.typography.titleSmall, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(R.string.message_history_title, channel.value), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + } } } } } // Filter suggestions above search bar + 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) }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index a271602ba..61d90ccb1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -1,12 +1,18 @@ package com.flxrs.dankchat.main.compose.sheets 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.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.height import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -20,6 +26,7 @@ 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 @@ -27,12 +34,14 @@ 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.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker import com.flxrs.dankchat.chat.replies.compose.RepliesComposable import com.flxrs.dankchat.chat.replies.compose.RepliesComposeViewModel import kotlinx.coroutines.CancellationException @@ -53,6 +62,7 @@ fun RepliesSheet( ) 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 @@ -62,6 +72,16 @@ fun RepliesSheet( 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 -> @@ -85,7 +105,6 @@ fun RepliesSheet( translationY = backProgress * 100f } ) { - // Chat content - edge to edge RepliesComposable( repliesViewModel = viewModel, onUserClick = onUserClick, @@ -93,56 +112,70 @@ fun RepliesSheet( onNotFound = onDismiss, containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), + scrollModifier = scrollModifier, modifier = Modifier.fillMaxSize(), ) - // Floating toolbar with gradient scrim - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .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), + AnimatedVisibility( + visible = toolbarVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = Modifier.align(Alignment.TopCenter), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top + 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), ) { - // Back navigation pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } } - } - // Title pill - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = stringResource(R.string.replies_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.surfaceContainer, + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + text = stringResource(R.string.replies_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + } } } } + + if (!toolbarVisible) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight) + .background(sheetBackgroundColor.copy(alpha = 0.7f)) + ) + } } } From ed927de221e1121a3da76450fb525ef4604698cc Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 10:56:07 +0100 Subject: [PATCH 089/349] fix(compose): Fix regressions and improve bottom padding, overflow menu, user popup, and sheet UX --- .../flxrs/dankchat/chat/compose/ChatScreen.kt | 2 +- .../chat/compose/ChatScrollBehavior.kt | 10 ++-- .../chat/mention/compose/MentionComposable.kt | 2 + .../chat/replies/compose/RepliesComposable.kt | 2 + .../chat/user/compose/UserPopupDialog.kt | 43 +++++++------- .../main/compose/ChatInputViewModel.kt | 4 +- .../flxrs/dankchat/main/compose/MainScreen.kt | 56 +++++++++++-------- .../main/compose/MainScreenDialogs.kt | 9 ++- .../main/compose/sheets/MentionSheet.kt | 1 + .../compose/sheets/MessageHistorySheet.kt | 1 + .../main/compose/sheets/RepliesSheet.kt | 1 + .../utils/compose/RoundedCornerPadding.kt | 22 ++++++-- 12 files changed, 99 insertions(+), 54 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 39c4a176c..2b73623eb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -232,7 +232,7 @@ fun ChatScreen( Box( modifier = Modifier .align(Alignment.BottomEnd) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp + fabBottomPadding), + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + fabBottomPadding), contentAlignment = Alignment.BottomEnd ) { if (recoveryFabTooltipState != null) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt index b3779cc22..2deeb5050 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt @@ -28,14 +28,16 @@ class ScrollDirectionTracker( ) : NestedScrollConnection { private var accumulated = 0f - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + 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 && available.y < 0f -> accumulated = 0f - accumulated < 0f && available.y > 0f -> accumulated = 0f + accumulated > 0f && delta < 0f -> accumulated = 0f + accumulated < 0f && delta > 0f -> accumulated = 0f } - accumulated += available.y + accumulated += delta when { accumulated > hideThresholdPx -> { onHide(); accumulated = 0f diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt index 3f95355d7..cd634c2e8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt @@ -37,6 +37,7 @@ fun MentionComposable( containerColor: Color, contentPadding: PaddingValues = PaddingValues(), scrollModifier: Modifier = Modifier, + onScrollToBottom: () -> Unit = {}, ) { val displaySettings by mentionViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val messages by when { @@ -62,6 +63,7 @@ fun MentionComposable( contentPadding = contentPadding, scrollModifier = scrollModifier, containerColor = containerColor, + onScrollToBottom = onScrollToBottom, ) } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt index e383c4a17..b6e35d114 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt @@ -36,6 +36,7 @@ fun RepliesComposable( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), scrollModifier: Modifier = Modifier, + onScrollToBottom: () -> Unit = {}, ) { val displaySettings by repliesViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) @@ -58,6 +59,7 @@ fun RepliesComposable( contentPadding = contentPadding, scrollModifier = scrollModifier, containerColor = containerColor, + onScrollToBottom = onScrollToBottom, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt index 310861ff0..4069296c0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt @@ -67,6 +67,7 @@ fun UserPopupDialog( onWhisper: (String) -> Unit, onOpenChannel: (String) -> Unit, onReport: (String) -> Unit, + isOwnUser: Boolean = false, onMessageHistory: ((String) -> Unit)? = null, ) { var showBlockConfirmation by remember { mutableStateOf(false) } @@ -112,15 +113,17 @@ fun UserPopupDialog( }, colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) - 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) - ) + if (!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) + ) + } if (onMessageHistory != null) { ListItem( headlineContent = { Text(stringResource(R.string.message_history)) }, @@ -132,7 +135,7 @@ fun UserPopupDialog( colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) } - if (isSuccess) { + 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) }, @@ -146,15 +149,17 @@ fun UserPopupDialog( colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) } - 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) - ) + 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) + ) + } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt index 1ebcd7774..21742dc50 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt @@ -364,7 +364,9 @@ class ChatInputViewModel( if (commandResult.command == TwitchCommand.Whisper) { chatRepository.fakeWhisperIfNecessary(message) } - if (commandResult.response != null) { + val isWhisperContext = chatState is FullScreenSheetState.Whisper || + (chatState is FullScreenSheetState.Mention && _whisperTarget.value != null) + if (commandResult.response != null && !isWhisperContext) { chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 2f0edfe63..84cf91fe6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -125,6 +125,8 @@ import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +private val ROUNDED_CORNER_THRESHOLD = 8.dp + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun MainScreen( @@ -545,6 +547,10 @@ fun MainScreen( // 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 @@ -818,10 +824,16 @@ fun MainScreen( contentPadding = PaddingValues( top = chatTopPadding + 56.dp, bottom = paddingValues.calculateBottomPadding() + when { - effectiveShowInput -> inputHeightDp - !isFullscreen -> max(helperTextHeightDp, max(navBarHeightDp, roundedCornerBottomPadding)) - useWideSplitLayout -> helperTextHeightDp - else -> max(helperTextHeightDp, roundedCornerBottomPadding) + effectiveShowInput -> inputHeightDp + !isFullscreen -> when { + helperTextHeightDp > 0.dp -> helperTextHeightDp + else -> max(navBarHeightDp, effectiveRoundedCorner) + } + + else -> when { + helperTextHeightDp > 0.dp -> helperTextHeightDp + else -> effectiveRoundedCorner + } } ), scrollModifier = chatScrollModifier, @@ -876,7 +888,7 @@ fun MainScreen( // Shared fullscreen sheet overlay val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> val effectiveBottomPadding = when { - !effectiveShowInput -> bottomPadding + max(navBarHeightDp, roundedCornerBottomPadding) + !effectiveShowInput -> bottomPadding + max(navBarHeightDp, effectiveRoundedCorner) else -> bottomPadding } FullScreenSheetOverlay( @@ -1128,23 +1140,7 @@ fun MainScreen( fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) } - // Input bar — on top of sheets for whisper/reply input - if (!isInPipMode) { - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ) - ) { - bottomBar() - } - } - - // Dismiss scrim for input overflow menu + // Dismiss scrim for input overflow menu — before input bar so menu items stay clickable if (!isInPipMode && inputOverflowExpanded) { Box( modifier = Modifier @@ -1160,6 +1156,22 @@ fun MainScreen( ) } + // Input bar — on top of sheets and dismiss scrim for whisper/reply input + if (!isInPipMode) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ) + ) { + bottomBar() + } + } + // Emote Menu Layer - slides up/down independently of keyboard // Fast tween to match system keyboard animation speed if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index 23304933b..f284e38cd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -24,6 +24,7 @@ import com.flxrs.dankchat.chat.user.compose.UserPopupDialog import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog import com.flxrs.dankchat.main.compose.dialogs.ConfirmationDialog import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog @@ -261,9 +262,12 @@ fun MainScreenDialogs( parameters = { parametersOf(params) } ) val state by viewModel.userPopupState.collectAsStateWithLifecycle() + val preferenceStore: DankChatPreferenceStore = koinInject() + val isOwnUser = preferenceStore.userIdString == params.targetUserId UserPopupDialog( state = state, badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }, + isOwnUser = isOwnUser, onBlockUser = viewModel::blockUser, onUnblockUser = viewModel::unblockUser, onDismiss = dialogViewModel::dismissUserPopup, @@ -274,9 +278,10 @@ fun MainScreenDialogs( ) }, onWhisper = { name -> - chatInputViewModel.updateInputText("/w $name ") + sheetNavigationViewModel.openWhispers() + chatInputViewModel.setWhisperTarget(UserName(name)) }, - onOpenChannel = { _ -> onOpenChannel() }, + onOpenChannel = { userName -> onOpenUrl("https://twitch.tv/$userName") }, onReport = { _ -> onReportChannel() }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt index 4252464c5..127312757 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt @@ -135,6 +135,7 @@ fun MentionSheet( containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), scrollModifier = scrollModifier, + onScrollToBottom = { toolbarVisible = true }, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt index 863a0dee0..93fb08e67 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt @@ -163,6 +163,7 @@ fun MessageHistorySheet( contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), scrollModifier = scrollModifier, containerColor = sheetBackgroundColor, + onScrollToBottom = { toolbarVisible = true }, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt index 61d90ccb1..f738cd2a3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt @@ -113,6 +113,7 @@ fun RepliesSheet( containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), scrollModifier = scrollModifier, + onScrollToBottom = { toolbarVisible = true }, modifier = Modifier.fillMaxSize(), ) 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 index 54a05b1d6..0438f9d17 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -106,8 +106,9 @@ fun Modifier.avoidRoundedCorners(fallback: PaddingValues): Modifier = composed { /** * Returns the bottom padding needed to avoid rounded display corners. - * Useful for [contentPadding][androidx.compose.foundation.lazy.LazyColumn] where a modifier - * would shrink the scrollable area instead of adding inset space. + * 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]. */ @@ -126,10 +127,21 @@ fun rememberRoundedCornerBottomPadding(fallback: Dp = 0.dp): Dp { val bottomLeft = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT) val bottomRight = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT) - val maxRadius = maxOf(bottomLeft?.radius ?: 0, bottomRight?.radius ?: 0) - if (maxRadius == 0) return fallback + 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() } +} - return with(density) { maxRadius.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) } /** From b43b563593d77ab53a085616e6c5c38e0a58be54 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 11:00:37 +0100 Subject: [PATCH 090/349] fix(compose): Hide input helper text when fullscreen sheet is open --- .../kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt index 1f7a57a06..e7718892c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt @@ -80,7 +80,7 @@ fun ChatBottomBar( showReplyOverlay = inputState.showReplyOverlay, replyName = inputState.replyName, isEmoteMenuOpen = inputState.isEmoteMenuOpen, - helperText = inputState.helperText, + helperText = if (isSheetOpen) null else inputState.helperText, isUploading = isUploading, isLoading = isLoading, isFullscreen = isFullscreen, From a1f0192dcb3a5ab4c16d18437f2fb4d275a6d72b Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 11:13:24 +0100 Subject: [PATCH 091/349] fix(compose): Add green background for FFZ custom mod badges and unify badge rendering --- .../chat/compose/messages/AutomodMessage.kt | 7 ++-- .../chat/compose/messages/PrivMessage.kt | 7 ++-- .../compose/messages/WhisperAndRedemption.kt | 7 ++-- .../compose/messages/common/InlineContent.kt | 32 ++++++++++++++++--- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt index 7840472d5..10e557546 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt @@ -28,6 +28,7 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus import com.flxrs.dankchat.chat.compose.EmoteDimensions +import com.flxrs.dankchat.chat.compose.messages.common.BadgeInlineContent import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.rememberNormalizedColor @@ -173,11 +174,7 @@ fun AutomodMessageComposable( buildMap { message.badges.forEach { badge -> put("BADGE_${badge.position}") { - coil3.compose.AsyncImage( - model = badge.drawableResId ?: badge.url, - contentDescription = badge.badge.type.name, - modifier = Modifier.size(badgeSize) - ) + BadgeInlineContent(badge = badge, size = badgeSize) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 21dcbda9f..64b34a02a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -43,6 +43,7 @@ import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.EmoteDimensions +import com.flxrs.dankchat.chat.compose.messages.common.BadgeInlineContent import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote @@ -260,11 +261,7 @@ private fun PrivMessageText( // Badge providers message.badges.forEach { badge -> put("BADGE_${badge.position}") { - coil3.compose.AsyncImage( - model = badge.drawableResId ?: badge.url, - contentDescription = badge.badge.type.name, - modifier = Modifier.size(badgeSize) - ) + BadgeInlineContent(badge = badge, size = badgeSize) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 93bf54f8f..5d641a6b8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -43,6 +43,7 @@ import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.EmoteDimensions +import com.flxrs.dankchat.chat.compose.messages.common.BadgeInlineContent import com.flxrs.dankchat.chat.compose.EmoteScaling import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.StackedEmote @@ -221,11 +222,7 @@ private fun WhisperMessageText( // Badge providers message.badges.forEach { badge -> put("BADGE_${badge.position}") { - coil3.compose.AsyncImage( - model = badge.url, - contentDescription = badge.badge.type.name, - modifier = Modifier.size(badgeSize) - ) + BadgeInlineContent(badge = badge, size = badgeSize) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt index 21387cdad..86ddd3af4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt @@ -1,17 +1,25 @@ package com.flxrs.dankchat.chat.compose.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.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImage import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.EmoteAnimationCoordinator import com.flxrs.dankchat.chat.compose.EmoteUi import com.flxrs.dankchat.chat.compose.StackedEmote +import com.flxrs.dankchat.data.twitch.badge.Badge + +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( @@ -19,11 +27,25 @@ fun BadgeInlineContent( size: Dp, modifier: Modifier = Modifier ) { - AsyncImage( - model = badge.url, - contentDescription = badge.badge.type.name, - modifier = modifier.size(size) - ) + 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() + ) + } + } + + else -> { + AsyncImage( + model = badge.drawableResId ?: badge.url, + contentDescription = badge.badge.type.name, + modifier = modifier.size(size) + ) + } + } } /** From 156e6ccfb4f9c92c194edab16f6eabc915c64949 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 11:34:43 +0100 Subject: [PATCH 092/349] fix(compose): Only show "Use emote" when input is active in sheets --- .../flxrs/dankchat/main/compose/MainScreenDialogs.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index f284e38cd..8c3d49e06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -242,9 +242,18 @@ fun MainScreenDialogs( key = emotes.joinToString { it.id }, parameters = { parametersOf(emotes) } ) + val sheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() + val whisperTarget by chatInputViewModel.whisperTarget.collectAsStateWithLifecycle() + 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 = isLoggedIn, + isLoggedIn = canUseEmote, onUseEmote = { chatInputViewModel.insertText("$it ") }, onCopyEmote = { scope.launch { From ae962aa54f279495f2a1978bd0b8790ed125408b Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 15:22:25 +0100 Subject: [PATCH 093/349] fix(compose): Flatten overflow menu, add icons, predictive back, and detach from toolbar --- .../dankchat/main/compose/FloatingToolbar.kt | 69 +- .../flxrs/dankchat/main/compose/MainAppBar.kt | 591 ++++-------------- .../flxrs/dankchat/main/compose/MainScreen.kt | 5 - 3 files changed, 154 insertions(+), 511 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt index d420833a5..80444dcc9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.main.compose import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandVertically @@ -25,6 +24,13 @@ 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.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.unit.Constraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -34,7 +40,6 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert @@ -107,9 +112,7 @@ sealed interface ToolbarAction { data object ReloadEmotes : ToolbarAction data object Reconnect : ToolbarAction data object ClearChat : ToolbarAction - data object ToggleStream : ToolbarAction data object OpenSettings : ToolbarAction - data object MessageHistory : ToolbarAction } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @@ -359,7 +362,7 @@ fun FloatingToolbar( onClick = { onAction(ToolbarAction.SelectTab(index)) }, onLongClick = { onAction(ToolbarAction.LongClickTab(index)) - overflowInitialMenu = AppBarMenu.Channel + overflowInitialMenu = AppBarMenu.Main showOverflowMenu = true } ) @@ -398,19 +401,9 @@ fun FloatingToolbar( Row(verticalAlignment = Alignment.Top) { Spacer(Modifier.width(8.dp)) - val pillCornerRadius by animateDpAsState( - targetValue = if (showOverflowMenu) 0.dp else 28.dp, - animationSpec = tween(200), - label = "pillCorner" - ) Column(modifier = Modifier.width(IntrinsicSize.Min)) { Surface( - shape = RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - bottomStart = pillCornerRadius, - bottomEnd = pillCornerRadius - ), + shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainer, ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -495,21 +488,17 @@ fun FloatingToolbar( AnimatedVisibility( visible = showOverflowMenu, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = Modifier + .padding(top = 4.dp) + .endAlignedOverflow(), ) { Surface( - shape = RoundedCornerShape( - topStart = 0.dp, - topEnd = 0.dp, - bottomStart = 12.dp, - bottomEnd = 12.dp - ), + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainer, ) { InlineOverflowMenu( isLoggedIn = isLoggedIn, - isStreamActive = currentStream != null, - hasStreamData = hasStreamData, onDismiss = { showOverflowMenu = false overflowInitialMenu = AppBarMenu.Main @@ -527,6 +516,36 @@ fun FloatingToolbar( } } +/** + * 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) + } +) + +private const val MAX_LAYOUT_SIZE = 16_777_215 + /** Measures [LazyRow] at full width (for scrolling) but reports actual content width so the pill wraps content. */ private fun Modifier.wrapLazyRowContent(listState: LazyListState, extraWidth: Int = 0) = layout { measurable, constraints -> val placeable = measurable.measure(constraints) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt index 5a418c085..7cb6513fc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.main.compose +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn @@ -9,478 +10,87 @@ 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.heightIn +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.size 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.filled.Add -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api +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.DeleteSweep +import androidx.compose.material.icons.filled.EditNote +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.automirrored.filled.Login +import androidx.compose.material.icons.automirrored.filled.Logout +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.EmojiEmotions +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar 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.Shape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException import com.flxrs.dankchat.R sealed interface AppBarMenu { data object Main : AppBarMenu - data object Account : AppBarMenu - data object Channel : AppBarMenu data object Upload : AppBarMenu - data object More : AppBarMenu -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainAppBar( - isLoggedIn: Boolean, - totalMentionCount: Int, - onAddChannel: () -> Unit, - onOpenMentions: () -> Unit, - onOpenWhispers: () -> Unit, - onLogin: () -> Unit, - onRelogin: () -> Unit, - onLogout: () -> Unit, - onManageChannels: () -> Unit, - onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, - onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, - onMessageHistory: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, - onClearChat: () -> Unit, - onOpenSettings: () -> Unit, - modifier: Modifier = Modifier -) { - var currentMenu by remember { mutableStateOf(null) } - - TopAppBar( - title = { Text(stringResource(R.string.app_name)) }, actions = { - // Add channel button (always visible) - IconButton(onClick = onAddChannel) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_channel) - ) - } - - IconButton(onClick = onOpenMentions) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.mentions_title), - tint = if (totalMentionCount > 0) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - } - ) - } - - // Overflow menu - IconButton(onClick = { currentMenu = AppBarMenu.Main }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more) - ) - } - - DropdownMenu( - expanded = currentMenu != null, - onDismissRequest = { currentMenu = null }, - shape = MaterialTheme.shapes.medium - ) { - 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 = "MenuTransition" - ) { menu -> - Column { - when (menu) { - AppBarMenu.Main -> { - if (!isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.login)) }, - onClick = { - onLogin() - currentMenu = null - } - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.account)) }, - onClick = { currentMenu = AppBarMenu.Account } - ) - } - - DropdownMenuItem( - text = { Text(stringResource(R.string.manage_channels)) }, - onClick = { - onManageChannels() - currentMenu = null - } - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.channel)) }, - onClick = { currentMenu = AppBarMenu.Channel } - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.upload_media)) }, - onClick = { currentMenu = AppBarMenu.Upload } - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.more)) }, - onClick = { currentMenu = AppBarMenu.More } - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.settings)) }, - onClick = { - onOpenSettings() - currentMenu = null - } - ) - } - - AppBarMenu.Account -> { - SubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.relogin)) }, - onClick = { - onRelogin() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.logout)) }, - onClick = { - onLogout() - currentMenu = null - } - ) - } - - AppBarMenu.Channel -> { - SubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.open_channel)) }, - onClick = { - onOpenChannel() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.message_history)) }, - onClick = { - onMessageHistory() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_channel)) }, - onClick = { - onRemoveChannel() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_channel)) }, - onClick = { - onReportChannel() - currentMenu = null - } - ) - if (isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_channel)) }, - onClick = { - onBlockChannel() - currentMenu = null - } - ) - } - } - - AppBarMenu.Upload -> { - SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.take_picture)) }, - onClick = { - onCaptureImage() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.record_video)) }, - onClick = { - onCaptureVideo() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.choose_media)) }, - onClick = { - onChooseMedia() - currentMenu = null - } - ) - } - - AppBarMenu.More -> { - SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.reload_emotes)) }, - onClick = { - onReloadEmotes() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.reconnect)) }, - onClick = { - onReconnect() - currentMenu = null - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.clear_chat)) }, - onClick = { - onClearChat() - currentMenu = null - } - ) - } - - null -> {} - } - } - } - } - }, - modifier = modifier - ) -} - -@Composable -fun ToolbarOverflowMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isLoggedIn: Boolean, - onLogin: () -> Unit, - onRelogin: () -> Unit, - onLogout: () -> Unit, - onManageChannels: () -> Unit, - onOpenChannel: () -> Unit, - onRemoveChannel: () -> Unit, - onReportChannel: () -> Unit, - onBlockChannel: () -> Unit, - onMessageHistory: () -> Unit, - onCaptureImage: () -> Unit, - onCaptureVideo: () -> Unit, - onChooseMedia: () -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, - onClearChat: () -> Unit, - onOpenSettings: () -> Unit, - shape: Shape = MaterialTheme.shapes.medium, - offset: DpOffset = DpOffset.Zero, -) { - var currentMenu by remember { mutableStateOf(AppBarMenu.Main) } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { - onDismiss() - currentMenu = AppBarMenu.Main - }, - shape = shape, - offset = offset - ) { - 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 = "MenuTransition" - ) { menu -> - Column { - when (menu) { - AppBarMenu.Main -> { - if (!isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.login)) }, - onClick = { onLogin(); onDismiss() } - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.account)) }, - onClick = { currentMenu = AppBarMenu.Account } - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.manage_channels)) }, - onClick = { onManageChannels(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.channel)) }, - onClick = { currentMenu = AppBarMenu.Channel } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.upload_media)) }, - onClick = { currentMenu = AppBarMenu.Upload } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.more)) }, - onClick = { currentMenu = AppBarMenu.More } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.settings)) }, - onClick = { onOpenSettings(); onDismiss() } - ) - } - - AppBarMenu.Account -> { - SubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.relogin)) }, - onClick = { onRelogin(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.logout)) }, - onClick = { onLogout(); onDismiss() } - ) - } - - AppBarMenu.Channel -> { - SubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.open_channel)) }, - onClick = { onOpenChannel(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.message_history)) }, - onClick = { onMessageHistory(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_channel)) }, - onClick = { onRemoveChannel(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_channel)) }, - onClick = { onReportChannel(); onDismiss() } - ) - if (isLoggedIn) { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_channel)) }, - onClick = { onBlockChannel(); onDismiss() } - ) - } - } - - AppBarMenu.Upload -> { - SubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.take_picture)) }, - onClick = { onCaptureImage(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.record_video)) }, - onClick = { onCaptureVideo(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.choose_media)) }, - onClick = { onChooseMedia(); onDismiss() } - ) - } - - AppBarMenu.More -> { - SubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) - DropdownMenuItem( - text = { Text(stringResource(R.string.reload_emotes)) }, - onClick = { onReloadEmotes(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.reconnect)) }, - onClick = { onReconnect(); onDismiss() } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.clear_chat)) }, - onClick = { onClearChat(); onDismiss() } - ) - } - - null -> {} - } - } - } - } -} - -@Composable -private fun SubMenuHeader(title: String, onBack: () -> Unit) { - DropdownMenuItem( - text = { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - }, - onClick = onBack - ) + data object Channel : AppBarMenu } @Composable fun InlineOverflowMenu( isLoggedIn: Boolean, - isStreamActive: Boolean = false, - hasStreamData: Boolean = false, onDismiss: () -> Unit, initialMenu: AppBarMenu = AppBarMenu.Main, onAction: (ToolbarAction) -> Unit, ) { var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } + var backProgress by remember { mutableFloatStateOf(0f) } + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + backProgress = 0f + when (currentMenu) { + AppBarMenu.Main -> onDismiss() + else -> currentMenu = AppBarMenu.Main + } + } catch (_: CancellationException) { + backProgress = 0f + } + } AnimatedContent( targetState = currentMenu, @@ -491,55 +101,64 @@ fun InlineOverflowMenu( (slideInHorizontally { -it } + fadeIn()).togetherWith(slideOutHorizontally { it } + fadeOut()) }.using(SizeTransform(clip = false)) }, - label = "InlineMenuTransition" + label = "InlineMenuTransition", + modifier = Modifier.graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + }, ) { menu -> - Column { + val density = LocalDensity.current + val screenHeight = with(density) { LocalView.current.height.toDp() } + Column( + modifier = Modifier + .width(200.dp) + .heightIn(max = screenHeight * 0.5f) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { when (menu) { - AppBarMenu.Main -> { + AppBarMenu.Main -> { if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login)) { onAction(ToolbarAction.Login); onDismiss() } + InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { onAction(ToolbarAction.Login); onDismiss() } } else { - InlineMenuItem(text = stringResource(R.string.account), hasSubMenu = true) { currentMenu = AppBarMenu.Account } + InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { onAction(ToolbarAction.Relogin); onDismiss() } + InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { onAction(ToolbarAction.Logout); onDismiss() } } - InlineMenuItem(text = stringResource(R.string.manage_channels)) { onAction(ToolbarAction.ManageChannels); onDismiss() } - InlineMenuItem(text = stringResource(R.string.channel), hasSubMenu = true) { currentMenu = AppBarMenu.Channel } - InlineMenuItem(text = stringResource(R.string.upload_media), hasSubMenu = true) { currentMenu = AppBarMenu.Upload } - InlineMenuItem(text = stringResource(R.string.more), hasSubMenu = true) { currentMenu = AppBarMenu.More } - InlineMenuItem(text = stringResource(R.string.settings)) { onAction(ToolbarAction.OpenSettings); onDismiss() } + + HorizontalDivider() + + InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { onAction(ToolbarAction.ManageChannels); onDismiss() } + InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { onAction(ToolbarAction.RemoveChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { onAction(ToolbarAction.ReloadEmotes); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { onAction(ToolbarAction.Reconnect); onDismiss() } + + HorizontalDivider() + + InlineMenuItem(text = stringResource(R.string.upload_media), icon = Icons.Default.CloudUpload, hasSubMenu = true) { currentMenu = AppBarMenu.Upload } + InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true) { currentMenu = AppBarMenu.Channel } + + HorizontalDivider() + + InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { onAction(ToolbarAction.OpenSettings); onDismiss() } } - AppBarMenu.Account -> { - InlineSubMenuHeader(title = stringResource(R.string.account), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.relogin)) { onAction(ToolbarAction.Relogin); onDismiss() } - InlineMenuItem(text = stringResource(R.string.logout)) { onAction(ToolbarAction.Logout); onDismiss() } + AppBarMenu.Upload -> { + InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { onAction(ToolbarAction.CaptureImage); onDismiss() } + InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { onAction(ToolbarAction.CaptureVideo); onDismiss() } + InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { onAction(ToolbarAction.ChooseMedia); onDismiss() } } AppBarMenu.Channel -> { InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - if (hasStreamData || isStreamActive) { - InlineMenuItem(text = stringResource(if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream)) { onAction(ToolbarAction.ToggleStream); onDismiss() } - } - InlineMenuItem(text = stringResource(R.string.open_channel)) { onAction(ToolbarAction.OpenChannel); onDismiss() } - InlineMenuItem(text = stringResource(R.string.message_history)) { onAction(ToolbarAction.MessageHistory); onDismiss() } - InlineMenuItem(text = stringResource(R.string.remove_channel)) { onAction(ToolbarAction.RemoveChannel); onDismiss() } - InlineMenuItem(text = stringResource(R.string.report_channel)) { onAction(ToolbarAction.ReportChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { onAction(ToolbarAction.OpenChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { onAction(ToolbarAction.ReportChannel); onDismiss() } if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel)) { onAction(ToolbarAction.BlockChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { onAction(ToolbarAction.BlockChannel); onDismiss() } } - } - - AppBarMenu.Upload -> { - InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.take_picture)) { onAction(ToolbarAction.CaptureImage); onDismiss() } - InlineMenuItem(text = stringResource(R.string.record_video)) { onAction(ToolbarAction.CaptureVideo); onDismiss() } - InlineMenuItem(text = stringResource(R.string.choose_media)) { onAction(ToolbarAction.ChooseMedia); onDismiss() } - } - - AppBarMenu.More -> { - InlineSubMenuHeader(title = stringResource(R.string.more), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.reload_emotes)) { onAction(ToolbarAction.ReloadEmotes); onDismiss() } - InlineMenuItem(text = stringResource(R.string.reconnect)) { onAction(ToolbarAction.Reconnect); onDismiss() } - InlineMenuItem(text = stringResource(R.string.clear_chat)) { onAction(ToolbarAction.ClearChat); onDismiss() } + InlineMenuItem(text = stringResource(R.string.clear_chat), icon = Icons.Default.DeleteSweep) { onAction(ToolbarAction.ClearChat); onDismiss() } } } } @@ -547,25 +166,35 @@ fun InlineOverflowMenu( } @Composable -private fun InlineMenuItem(text: String, hasSubMenu: Boolean = false, onClick: () -> Unit) { +private fun InlineMenuItem(text: String, icon: ImageVector, hasSubMenu: Boolean = false, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + .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.bodyMedium, + style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) if (hasSubMenu) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), ) } } @@ -577,7 +206,7 @@ private fun InlineSubMenuHeader(title: String, onBack: () -> Unit) { modifier = Modifier .fillMaxWidth() .clickable(onClick = onBack) - .padding(horizontal = 12.dp, vertical = 12.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt index 84cf91fe6..fa08be298 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt @@ -683,12 +683,7 @@ fun MainScreen( } ToolbarAction.ClearChat -> dialogViewModel.showClearChat() - ToolbarAction.ToggleStream -> when { - currentStream != null -> streamViewModel.closeStream() - else -> activeChannel?.let { streamViewModel.toggleStream(it) } - } ToolbarAction.OpenSettings -> onNavigateToSettings() - ToolbarAction.MessageHistory -> activeChannel?.let { sheetNavigationViewModel.openHistory(it) } } } From 0edbaaca278cc3fea02880d6cca8afb3e7750ce6 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 15:59:02 +0100 Subject: [PATCH 094/349] fix(compose): Disable rounded highlights when line separators are enabled --- .../kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt index 2b73623eb..0b443479d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt @@ -192,7 +192,7 @@ fun ChatScreen( // reverseLayout=true: index 0 = bottom (newest), index+1 = visually above val highlightedBelow = reversedMessages.getOrNull(index - 1)?.isHighlighted == true val highlightedAbove = reversedMessages.getOrNull(index + 1)?.isHighlighted == true - val highlightShape = message.highlightShape(highlightedAbove, highlightedBelow) + val highlightShape = message.highlightShape(highlightedAbove, highlightedBelow, showLineSeparator) ChatMessageItem( message = message, highlightShape = highlightShape, @@ -317,8 +317,9 @@ private fun RecoveryFab( private val HIGHLIGHT_CORNER_RADIUS = 8.dp -private fun ChatMessageUiState.highlightShape(highlightedAbove: Boolean, highlightedBelow: Boolean): Shape { +private fun ChatMessageUiState.highlightShape(highlightedAbove: Boolean, highlightedBelow: Boolean, showLineSeparator: Boolean): Shape { if (!isHighlighted) return RectangleShape + if (showLineSeparator) return RectangleShape val top = if (highlightedAbove) 0.dp else HIGHLIGHT_CORNER_RADIUS val bottom = if (highlightedBelow) 0.dp else HIGHLIGHT_CORNER_RADIUS return RoundedCornerShape(topStart = top, topEnd = top, bottomStart = bottom, bottomEnd = bottom) From 71b7b0555e6cae98a7323d850f84f9d02f4f4abc Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 18:38:59 +0100 Subject: [PATCH 095/349] fix: Use localized strings for data loading failure snackbar and track Twitch emote failures --- .../data/repo/chat/ChatLoadingStep.kt | 12 +++-- .../data/repo/data/DataLoadingStep.kt | 51 ++++++++++--------- .../dankchat/data/repo/data/DataRepository.kt | 5 +- .../data/repo/emote/EmoteRepository.kt | 14 ++--- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 4 +- .../main/compose/MainScreenEventHandler.kt | 12 +++-- app/src/main/res/values-be-rBY/strings.xml | 13 +++++ app/src/main/res/values-ca/strings.xml | 13 +++++ app/src/main/res/values-cs/strings.xml | 13 +++++ app/src/main/res/values-de-rDE/strings.xml | 13 +++++ app/src/main/res/values-en-rAU/strings.xml | 13 +++++ app/src/main/res/values-en-rGB/strings.xml | 13 +++++ app/src/main/res/values-en/strings.xml | 13 +++++ app/src/main/res/values-es-rES/strings.xml | 13 +++++ app/src/main/res/values-fi-rFI/strings.xml | 13 +++++ app/src/main/res/values-fr-rFR/strings.xml | 13 +++++ app/src/main/res/values-hu-rHU/strings.xml | 13 +++++ app/src/main/res/values-it/strings.xml | 13 +++++ app/src/main/res/values-ja-rJP/strings.xml | 14 ++++- app/src/main/res/values-pl-rPL/strings.xml | 13 +++++ app/src/main/res/values-pt-rBR/strings.xml | 13 +++++ app/src/main/res/values-pt-rPT/strings.xml | 13 +++++ app/src/main/res/values-ru-rRU/strings.xml | 13 +++++ app/src/main/res/values-sr/strings.xml | 13 +++++ app/src/main/res/values-tr-rTR/strings.xml | 13 +++++ app/src/main/res/values-uk-rUA/strings.xml | 13 +++++ app/src/main/res/values/strings.xml | 13 +++++ 27 files changed, 329 insertions(+), 43 deletions(-) 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..c251f79ce 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,23 @@ 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/data/DataLoadingStep.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingStep.kt index dc69f1c42..2778e1cee 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,51 +1,54 @@ 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 { - - data object DankChatBadges : DataLoadingStep - - data object GlobalBadges : DataLoadingStep - - data object GlobalFFZEmotes : DataLoadingStep - - data object GlobalBTTVEmotes : DataLoadingStep - - data object GlobalSevenTVEmotes : DataLoadingStep - - 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 ChannelCheermotes(val channel: UserName, val channelId: UserId) : DataLoadingStep + @get:StringRes val displayNameRes: Int + + 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 TwitchEmotes : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_twitch_emotes } + + data class ChannelBadges(val channel: UserName, val channelId: UserId) : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_channel_badges } + data class ChannelFFZEmotes(val channel: UserName, val channelId: UserId) : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_ffz_emotes } + 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 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 e37d5e800..a3b83ba36 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 @@ -147,8 +147,9 @@ class DataRepository( } } - suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) { - emoteRepository.loadUserEmotes(userId, onFirstPageLoaded) + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result { + return emoteRepository.loadUserEmotes(userId, onFirstPageLoaded) + .getOrEmitFailure { DataLoadingStep.TwitchEmotes } } suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) { 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 a465e2f13..4b3f40b98 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,8 +1,10 @@ package com.flxrs.dankchat.data.repo.emote +import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.util.Log +import androidx.core.graphics.toColorInt import android.util.LruCache import androidx.annotation.VisibleForTesting import com.flxrs.dankchat.data.DisplayName @@ -18,7 +20,7 @@ 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.HelixApiException + 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 @@ -332,11 +334,9 @@ class EmoteRepository( fun getSevenTVUserDetails(channel: UserName): SevenTVUserDetails? = sevenTvChannelDetails[channel] - suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) { - try { + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result { + return runCatching { loadUserEmotesViaHelix(userId, onFirstPageLoaded) - } catch (e: HelixApiException) { - Log.e(TAG, "Failed to load user emotes", e) } } @@ -584,9 +584,9 @@ class EmoteRepository( CheermoteTier( minBits = tier.minBits, color = try { - android.graphics.Color.parseColor(tier.color) + tier.color.toColorInt() } catch (_: IllegalArgumentException) { - android.graphics.Color.GRAY + 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(), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index fd482cbd9..1a0d43f10 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -41,8 +41,8 @@ class GlobalDataLoader( suspend fun loadSupibotCommands() = commandRepository.loadSupibotCommands() suspend fun loadUserBlocks() = ignoresRepository.loadUserBlocks() - suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) { - dataRepository.loadUserEmotes(userId, onFirstPageLoaded) + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result { + return dataRepository.loadUserEmotes(userId, onFirstPageLoaded) } suspend fun loadUserStateEmotes( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt index c104f78da..8c315e92e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt @@ -15,6 +15,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.auth.AuthEvent import com.flxrs.dankchat.auth.AuthStateCoordinator +import com.flxrs.dankchat.data.repo.chat.toDisplayStrings +import com.flxrs.dankchat.data.repo.data.toDisplayStrings import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.main.MainActivity import com.flxrs.dankchat.main.MainEvent @@ -113,11 +115,13 @@ fun MainScreenEventHandler( val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() LaunchedEffect(loadingState) { val state = loadingState as? GlobalLoadingState.Failed ?: return@LaunchedEffect - val failedSteps = state.failures.map { it.step } + state.chatFailures.map { it.step } - val stepsText = failedSteps.joinToString(", ") { it::class.simpleName.orEmpty() } + 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 { - failedSteps.size == 1 -> resources.getString(R.string.snackbar_data_load_failed_cause, stepsText) - else -> resources.getString(R.string.snackbar_data_load_failed_multiple_causes, stepsText) + 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, diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 77ff7b8b7..93212721d 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -64,6 +64,19 @@ Смайлы абноўлены Памылка загрузкі дадзеных: %1$s Не ўдалося загрузіць даныя: некалькі памылак:\n%1$s + Значкі DankChat + Глабальныя значкі + Глабальныя FFZ-эмоўты + Глабальныя BTTV-эмоўты + Глабальныя 7TV-эмоўты + Значкі канала + FFZ-эмоўты + BTTV-эмоўты + 7TV-эмоўты + Twitch-эмоўты + Cheermote-ы + Апошнія паведамленні + %1$s (%2$s) Уставіць Назва канала Нядаўнія diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 979438998..326412856 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -60,6 +60,19 @@ Reintentar Emotes recargats Càrrega de dades fallida: %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) Enganxa Nom del canal Recent diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6cf89b7e6..98431f1ff 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -64,6 +64,19 @@ 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) Vložit Název kanálu Nedávné diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 6a841e830..a1d0b0417 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -64,6 +64,19 @@ 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) Einfügen Kanalname Zuletzt diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index edd23cb7a..400203a75 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -60,6 +60,19 @@ 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) Paste Channel name Recent diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 5958e5149..06caa0dfd 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -60,6 +60,19 @@ 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) Paste Channel name Recent diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d5194b64a..01a9df73f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -64,6 +64,19 @@ 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) Paste Channel name Recent diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 6981403e4..6e3c6ad99 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -63,6 +63,19 @@ 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) Pegar Nombre del canal Reciente diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 6595e7eb3..eac53fff9 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -63,6 +63,19 @@ 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) Liitä Kanavan nimi Viimeisimmät diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index c49ff33ae..1d25b0b7b 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -64,6 +64,19 @@ 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) Coller Nom de la chaîne Récentes diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 1f3b9b4f2..45a10e9b8 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -64,6 +64,19 @@ 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) Beillesztés Csatorna neve Legutóbbi diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d5ca5230b..4bcca7618 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -63,6 +63,19 @@ 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) Incolla Nome del canale Recente diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index bf887ad30..4a9eabe00 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -63,6 +63,19 @@ エモートをリロードしました データの読み込みに失敗しました:%1$s 複数のエラーでデータの読み込みに失敗しました:\n%1$s + DankChat バッジ + グローバルバッジ + グローバル FFZ エモート + グローバル BTTV エモート + グローバル 7TV エモート + チャンネルバッジ + FFZ エモート + BTTV エモート + 7TV エモート + Twitch エモート + チアエモート + 最近のメッセージ + %1$s (%2$s) 貼り付け チャンネル名 新着 @@ -427,7 +440,6 @@ 期限切れ %1$s (レベル %2$d) - %1$d件のブロックされた用語 %2$s に一致 %1$d件のブロックされた用語 %2$s に一致 AutoModメッセージの%1$sに失敗しました - メッセージは既に処理されています。 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index efa5531be..2e558c6f6 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -63,6 +63,19 @@ 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) Wklej Nazwa kanału Ostatnie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 06cf0289b..6f5e16200 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -64,6 +64,19 @@ 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) Colar Nome do canal Recente diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b91ed0b2d..f8f5aeed2 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -64,6 +64,19 @@ 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) Colar Nome do canal Recente diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index c88384eb4..5557e37aa 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -64,6 +64,19 @@ Смайлы обновлены Ошибка загрузки данных: %1$s Загрузка данных не удалась из-за нескольких ошибок:\n%1$s + Значки DankChat + Глобальные значки + Глобальные FFZ-эмоуты + Глобальные BTTV-эмоуты + Глобальные 7TV-эмоуты + Значки канала + FFZ-эмоуты + BTTV-эмоуты + 7TV-эмоуты + Twitch-эмоуты + Cheermote-ы + Последние сообщения + %1$s (%2$s) Вставить Имя канала Недавние diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 04a25ed2b..13a7e5517 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -55,6 +55,19 @@ Pokušaj ponovo Emotovi su osveženi Učitavanje podataka nije uspelo: %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) Paste Naziv kanala Pretplatnici diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 9e052c9a3..ce18629be 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -63,6 +63,19 @@ İ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) Yapıştır Kanal adı Son kullanılanlar diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 1b2191aee..9a0046f2f 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -64,6 +64,19 @@ Смайли оновлені Помилка завантаження даних: %1$s Не вдалося завантажити дані через кількох помилок:\n%1$s + Значки DankChat + Глобальні значки + Глобальні FFZ-емоути + Глобальні BTTV-емоути + Глобальні 7TV-емоути + Значки каналу + FFZ-емоути + BTTV-емоути + 7TV-емоути + Twitch-емоути + Cheermote-и + Останні повідомлення + %1$s (%2$s) Вставити Ім\'я каналу Останні diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61f4b8dcc..f67246910 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -69,6 +69,19 @@ 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) Paste Channel name Recent From c632583e6d6c717bbf30bfa26d529719982eb64d Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 18:39:05 +0100 Subject: [PATCH 096/349] fix: Fix channel data retry not awaiting completion and wire 7TV unsubscribe on channel removal --- .../dankchat/domain/ChannelDataCoordinator.kt | 56 +++++++++++-------- .../dankchat/domain/ChannelDataLoader.kt | 29 ++-------- 2 files changed, 38 insertions(+), 47 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index e2a6833a0..e26b0e7ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -1,11 +1,9 @@ package com.flxrs.dankchat.domain -import android.util.Log import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName 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 @@ -94,19 +92,17 @@ class ChannelDataCoordinator( */ fun loadChannelData(channel: UserName) { scope.launch { - val stateFlow = channelStates.getOrPut(channel) { - MutableStateFlow(ChannelLoadingState.Idle) - } - - channelDataLoader.loadChannelData(channel) - .collect { state -> - stateFlow.value = state - } + loadChannelDataSuspend(channel) + } + } - // Reparse immediately with whatever emotes are available now - // Don't wait for globalLoadJob — channel 3rd party emotes should show immediately - chatMessageRepository.reparseAllEmotesAndBadges() + private suspend fun loadChannelDataSuspend(channel: UserName) { + val stateFlow = channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) } + stateFlow.value = ChannelLoadingState.Loading + stateFlow.value = channelDataLoader.loadChannelData(channel) + chatMessageRepository.reparseAllEmotesAndBadges() } /** @@ -128,13 +124,9 @@ class ChannelDataCoordinator( if (userId != null) { val firstPageLoaded = CompletableDeferred() launch { - try { - globalDataLoader.loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } - chatMessageRepository.reparseAllEmotesAndBadges() - } catch (e: Exception) { - Log.e(TAG, "Failed to load user emotes", e) - firstPageLoaded.complete(Unit) - } + globalDataLoader.loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } + .onSuccess { chatMessageRepository.reparseAllEmotesAndBadges() } + .onFailure { firstPageLoaded.complete(Unit) } } firstPageLoaded.await() chatMessageRepository.reparseAllEmotesAndBadges() @@ -166,6 +158,9 @@ class ChannelDataCoordinator( */ fun cleanupChannel(channel: UserName) { channelStates.remove(channel) + scope.launch { + dataRepository.removeChannels(listOf(channel)) + } } /** @@ -205,6 +200,20 @@ class ChannelDataCoordinator( 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) @@ -221,7 +230,9 @@ class ChannelDataCoordinator( } dataResults.awaitAll() - channelsToRetry.forEach { loadChannelData(it) } + channelsToRetry.map { channel -> + async { loadChannelDataSuspend(channel) } + }.awaitAll() val remainingDataFailures = dataRepository.dataLoadingFailures.value val remainingChatFailures = chatMessageRepository.chatLoadingFailures.value @@ -235,7 +246,4 @@ class ChannelDataCoordinator( } } - companion object { - private val TAG = ChannelDataCoordinator::class.java.simpleName - } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index f7e2b62ed..c1c5926ca 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -13,8 +13,6 @@ 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.flow.Flow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import org.koin.core.annotation.Single @@ -28,32 +26,19 @@ class ChannelDataLoader( private val dispatchersProvider: DispatchersProvider ) { - /** - * Load all data for a single channel. - * Returns a Flow of loading state for this channel. - */ - fun loadChannelData(channel: UserName): Flow = flow { - emit(ChannelLoadingState.Loading) - - try { - // Get channel info - uses GetChannelsUseCase which waits for IRC ROOMSTATE - // if not logged in, matching the legacy MainViewModel.loadData behavior + suspend fun loadChannelData(channel: UserName): ChannelLoadingState { + return try { val channelInfo = channelRepository.getChannel(channel) ?: getChannelsUseCase(listOf(channel)).firstOrNull() if (channelInfo == null) { - emit(ChannelLoadingState.Failed(emptyList())) - return@flow + return ChannelLoadingState.Failed(emptyList()) } - // Create flows if necessary dataRepository.createFlowsIfNecessary(listOf(channel)) chatRepository.createFlowsIfNecessary(channel) - // Load recent message history first with priority - // loadRecentMessagesIfEnabled handles errors internally and posts its own system messages chatRepository.loadRecentMessagesIfEnabled(channel) - // Load other data in parallel val failures = withContext(dispatchersProvider.io) { val badgesResult = async { loadChannelBadges(channel, channelInfo.id) } val emotesResults = async { loadChannelEmotes(channel, channelInfo) } @@ -64,7 +49,6 @@ class ChannelDataLoader( ) } - // Report failures as system messages like legacy implementation failures.forEach { failure -> val status = (failure.error as? ApiException)?.status?.value?.toString() ?: "0" val systemMessageType = when (failure) { @@ -79,11 +63,11 @@ class ChannelDataLoader( } when { - failures.isEmpty() -> emit(ChannelLoadingState.Loaded) - else -> emit(ChannelLoadingState.Failed(failures)) + failures.isEmpty() -> ChannelLoadingState.Loaded + else -> ChannelLoadingState.Failed(failures) } } catch (e: Exception) { - emit(ChannelLoadingState.Failed(emptyList())) + ChannelLoadingState.Failed(emptyList()) } } @@ -136,7 +120,6 @@ class ChannelDataLoader( } suspend fun loadRecentMessages(channel: UserName) { - // loadRecentMessagesIfEnabled handles errors internally and posts its own system messages chatRepository.loadRecentMessagesIfEnabled(channel) } } From 991864d226719d646b6488f505bd681d322a65bf Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 18:39:36 +0100 Subject: [PATCH 097/349] fix: Fix connection state not updating for all channels and broadcast Connected system message --- .../com/flxrs/dankchat/data/repo/chat/ChatConnector.kt | 6 +++--- .../flxrs/dankchat/data/repo/chat/ChatEventProcessor.kt | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) 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 index 31fc1c59f..13f547ee1 100644 --- 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 @@ -38,12 +38,12 @@ class ChatConnector( connectionState.getOrPut(channel) { MutableStateFlow(ConnectionState.DISCONNECTED) } fun setConnectionState(channel: UserName, state: ConnectionState) { - connectionState[channel]?.value = state + connectionState.getOrPut(channel) { MutableStateFlow(state) }.value = state } fun setAllConnectionStates(state: ConnectionState) { - connectionState.keys.forEach { - connectionState[it]?.value = state + connectionState.forEach { (_, flow) -> + flow.value = state } } 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 index 7ec86d359..1c7345717 100644 --- 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 @@ -283,8 +283,11 @@ class ChatEventProcessor( isAnonymous -> ConnectionState.CONNECTED_NOT_LOGGED_IN else -> ConnectionState.CONNECTED } - postSystemMessageAndReconnect(state.toSystemMessageType(), setOf(channel)) - chatConnector.setConnectionState(channel, state) + val previousState = chatConnector.getConnectionState(channel).value + chatConnector.setAllConnectionStates(state) + if (previousState != state) { + postSystemMessageAndReconnect(state.toSystemMessageType()) + } } private fun handleClearChat(msg: IrcMessage) { From 2799f4b8b692bb79147963a4e6778b9d6e52803c Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 18:39:49 +0100 Subject: [PATCH 098/349] refactor: Self-initialize ChatChannelProvider from preferences and simplify DankChatViewModel --- app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt | 6 +----- .../flxrs/dankchat/data/repo/chat/ChatChannelProvider.kt | 5 +++-- .../com/flxrs/dankchat/data/repo/chat/ChatRepository.kt | 4 ++++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 4abf8e2ab..3f475d13c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -7,7 +7,6 @@ import com.flxrs.dankchat.auth.AuthStateCoordinator 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.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -23,7 +22,6 @@ import kotlin.time.Duration.Companion.seconds class DankChatViewModel( private val chatChannelProvider: ChatChannelProvider, private val chatConnector: ChatConnector, - private val preferenceStore: DankChatPreferenceStore, private val authDataStore: AuthDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val dataRepository: DataRepository, @@ -51,9 +49,7 @@ class DankChatViewModel( viewModelScope.launch { authStateCoordinator.validateOnStartup() initialConnectionStarted = true - val channels = preferenceStore.channels - chatChannelProvider.setChannels(channels) - chatConnector.connectAndJoin(channels) + chatConnector.connectAndJoin(chatChannelProvider.channels.value.orEmpty()) } } 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 index 36c13ba26..5eb80e9cd 100644 --- 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 @@ -1,16 +1,17 @@ package com.flxrs.dankchat.data.repo.chat import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.preferences.DankChatPreferenceStore import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.koin.core.annotation.Single @Single -class ChatChannelProvider { +class ChatChannelProvider(preferenceStore: DankChatPreferenceStore) { private val _activeChannel = MutableStateFlow(null) - private val _channels = MutableStateFlow?>(null) + private val _channels = MutableStateFlow?>(preferenceStore.channels.takeIf { it.isNotEmpty() }) val activeChannel: StateFlow = _activeChannel.asStateFlow() val channels: StateFlow?> = _channels.asStateFlow() 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 594877437..6d18e2129 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 @@ -37,6 +37,10 @@ class ChatRepository( val activeChannel get() = chatChannelProvider.activeChannel val channels get() = chatChannelProvider.channels + init { + chatChannelProvider.channels.value?.forEach { createFlowsIfNecessary(it) } + } + fun setActiveChannel(channel: UserName?) = chatChannelProvider.setActiveChannel(channel) fun joinChannel(channel: UserName, listenToPubSub: Boolean = true): List { From 801df1be4674837009c69b7ed0de88cee3adb804 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 18:40:58 +0100 Subject: [PATCH 099/349] fix(lint): Fix LocalContextResourcesRead, ModifierParameter order, UnusedQuantity, and suppress noisy warnings --- app/build.gradle.kts | 2 ++ .../flxrs/dankchat/chat/compose/messages/PrivMessage.kt | 2 +- .../dankchat/chat/compose/messages/SystemMessages.kt | 2 +- .../chat/compose/messages/WhisperAndRedemption.kt | 2 +- .../preferences/notifications/ignores/IgnoresScreen.kt | 4 ++-- app/src/main/res/values-night/themes.xml | 8 ++++---- app/src/main/res/values/themes.xml | 8 ++++---- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1eaf33cb3..23955a56c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,6 +95,8 @@ android { lint { disable += "RestrictedApi" + disable += "UnusedResources" + disable += "ObsoleteSdkInt" } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 64b34a02a..05beefe71 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -67,9 +67,9 @@ import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @Composable fun PrivMessageComposable( message: ChatMessageUiState.PrivMessageUi, - highlightShape: Shape = RectangleShape, fontSize: Float, modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, showChannelPrefix: Boolean = false, animateGifs: Boolean = true, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index 053af0301..9dcdcb7d5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -83,9 +83,9 @@ fun NoticeMessageComposable( @Composable fun UserNoticeMessageComposable( message: ChatMessageUiState.UserNoticeMessageUi, - highlightShape: Shape = RectangleShape, fontSize: Float, modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, ) { val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = MaterialTheme.colorScheme.onSurface diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 5d641a6b8..46fcd74c3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -315,9 +315,9 @@ private fun WhisperMessageText( @Composable fun PointRedemptionMessageComposable( message: ChatMessageUiState.PointRedemptionMessageUi, - highlightShape: Shape = RectangleShape, fontSize: Float, modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, ) { val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val timestampColor = rememberAdaptiveTextColor(backgroundColor) 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 c7b0dcec2..0a3192155 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,7 +60,7 @@ 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.LocalResources import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource @@ -121,7 +121,7 @@ private fun IgnoresScreen( onPageChanged: (Int) -> Unit, onNavBack: () -> Unit, ) { - val resources = LocalContext.current.resources + val resources = LocalResources.current val focusManager = LocalFocusManager.current val snackbarHost = remember { SnackbarHostState() } val pagerState = rememberPagerState { IgnoresTab.entries.size } diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 04ada92fc..7a823a4f7 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,12 +1,12 @@ - + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 824efe02e..d962a6987 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,12 +1,12 @@ - + From 2a9f7950bbce36d49f6fbe35aaa9f8fde3ba04c0 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 20:02:18 +0100 Subject: [PATCH 100/349] fix: Read stream settings on each refresh tick instead of once at start --- .../flxrs/dankchat/data/repo/stream/StreamDataRepository.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 5172ed1ba..0c484fbf6 100644 --- 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 @@ -44,10 +44,11 @@ class StreamDataRepository( } fetchTimerJob = timer(STREAM_REFRESH_RATE) { + val currentSettings = streamsSettingsDataStore.settings.first() val data = dataRepository.getStreams(channels)?.map { val uptime = DateTimeUtils.calculateUptime(it.startedAt) val category = it.category - ?.takeIf { settings.showStreamCategory } + ?.takeIf { currentSettings.showStreamCategory } ?.ifBlank { null } val formatted = dankChatPreferenceStore.formatViewersString(it.viewerCount, uptime, category) From 8e90445bae6558b6219e617a1b42a0d17bae7836 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 20:28:58 +0100 Subject: [PATCH 101/349] feat(compose): Add highlight type headers for First Time Chat and Elevated Chat messages --- .../chat/compose/ChatMessageMapper.kt | 10 +++++++ .../chat/compose/ChatMessageUiState.kt | 1 + .../chat/compose/messages/PrivMessage.kt | 28 +++++++++++++++++++ app/src/main/res/values-be-rBY/strings.xml | 3 ++ app/src/main/res/values-ca/strings.xml | 3 ++ app/src/main/res/values-cs/strings.xml | 3 ++ app/src/main/res/values-de-rDE/strings.xml | 3 ++ app/src/main/res/values-en-rAU/strings.xml | 3 ++ app/src/main/res/values-en-rGB/strings.xml | 3 ++ app/src/main/res/values-en/strings.xml | 3 ++ app/src/main/res/values-es-rES/strings.xml | 3 ++ app/src/main/res/values-fi-rFI/strings.xml | 3 ++ app/src/main/res/values-fr-rFR/strings.xml | 3 ++ app/src/main/res/values-hu-rHU/strings.xml | 3 ++ app/src/main/res/values-it/strings.xml | 3 ++ app/src/main/res/values-ja-rJP/strings.xml | 3 ++ app/src/main/res/values-pl-rPL/strings.xml | 3 ++ app/src/main/res/values-pt-rBR/strings.xml | 3 ++ app/src/main/res/values-pt-rPT/strings.xml | 3 ++ app/src/main/res/values-ru-rRU/strings.xml | 3 ++ app/src/main/res/values-sr/strings.xml | 3 ++ app/src/main/res/values-tr-rTR/strings.xml | 3 ++ app/src/main/res/values-uk-rUA/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ 24 files changed, 102 insertions(+) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt index 48608708c..08273afa2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt @@ -11,6 +11,7 @@ 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.highestPriorityHighlight import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.ModerationMessage import com.flxrs.dankchat.data.twitch.message.NoticeMessage @@ -387,6 +388,14 @@ class ChatMessageMapper( thread.toThreadUi() } else null + val highlightHeader = highlights.highestPriorityHighlight()?.let { + when (it.type) { + HighlightType.FirstMessage -> TextResource.Res(R.string.highlight_header_first_time_chat) + HighlightType.ElevatedMessage -> TextResource.Res(R.string.highlight_header_elevated_chat) + else -> null + } + } + val fullMessage = buildString { if (isMentionTab && highlights.any { it.isMention }) { append("#$channel ") @@ -421,6 +430,7 @@ class ChatMessageMapper( emotes = emoteUis, isAction = isAction, thread = threadUi, + highlightHeader = highlightHeader, fullMessage = fullMessage ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt index 0cf2c475f..773f4a026 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt @@ -51,6 +51,7 @@ sealed interface ChatMessageUiState { val emotes: ImmutableList, val isAction: Boolean, val thread: ThreadUi?, + val highlightHeader: TextResource? = null, val fullMessage: String, // For copying ) : ChatMessageUiState diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index 05beefe71..c6e5f4905 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -16,6 +16,7 @@ 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.MaterialTheme @@ -40,6 +41,7 @@ import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.core.net.toUri import coil3.compose.LocalPlatformContext +import com.flxrs.dankchat.chat.compose.resolve import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.EmoteDimensions @@ -89,6 +91,32 @@ fun PrivMessageComposable( .indication(interactionSource, ripple()) .padding(horizontal = 2.dp, vertical = 2.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, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + // Reply thread header if (message.thread != null) { Row( diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 93212721d..4e46fcbcb 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -77,6 +77,9 @@ Cheermote-ы Апошнія паведамленні %1$s (%2$s) + + Першае паведамленне + Павышанае паведамленне Уставіць Назва канала Нядаўнія diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 326412856..9b4ddffb9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -73,6 +73,9 @@ Cheermotes Missatges recents %1$s (%2$s) + + Primer missatge + Missatge destacat Enganxa Nom del canal Recent diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 98431f1ff..7fc0f2217 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -77,6 +77,9 @@ Cheermoty Nedávné zprávy %1$s (%2$s) + + První zpráva + Zvýrazněná zpráva Vložit Název kanálu Nedávné diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index a1d0b0417..49c324b7c 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -77,6 +77,9 @@ Cheermotes Letzte Nachrichten %1$s (%2$s) + + Erste Nachricht + Hervorgehobene Nachricht Einfügen Kanalname Zuletzt diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 400203a75..fa2be7802 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -73,6 +73,9 @@ Cheermotes Recent Messages %1$s (%2$s) + + First Time Chat + Elevated Chat Paste Channel name Recent diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 06caa0dfd..da5c74e58 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -73,6 +73,9 @@ Cheermotes Recent Messages %1$s (%2$s) + + First Time Chat + Elevated Chat Paste Channel name Recent diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 01a9df73f..b4a823b21 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -77,6 +77,9 @@ Cheermotes Recent Messages %1$s (%2$s) + + First Time Chat + Elevated Chat Paste Channel name Recent diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 6e3c6ad99..6ed8fe0ba 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -76,6 +76,9 @@ Cheermotes Mensajes recientes %1$s (%2$s) + + Primer mensaje + Mensaje destacado Pegar Nombre del canal Reciente diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index eac53fff9..b67dfc348 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -76,6 +76,9 @@ Cheermotit Viimeaikaiset viestit %1$s (%2$s) + + Ensimmäinen viesti + Korostettu viesti Liitä Kanavan nimi Viimeisimmät diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 1d25b0b7b..7cb7fbeb6 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -77,6 +77,9 @@ Cheermotes Messages récents %1$s (%2$s) + + Premier message + Message mis en avant Coller Nom de la chaîne Récentes diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 45a10e9b8..76824eae8 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -77,6 +77,9 @@ Cheermote-ok Legutóbbi üzenetek %1$s (%2$s) + + Első üzenet + Kiemelt üzenet Beillesztés Csatorna neve Legutóbbi diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4bcca7618..4ad6f0056 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -76,6 +76,9 @@ Cheermote Messaggi recenti %1$s (%2$s) + + Primo messaggio + Messaggio elevato Incolla Nome del canale Recente diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 4a9eabe00..5a118a716 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -76,6 +76,9 @@ チアエモート 最近のメッセージ %1$s (%2$s) + + 初めてのチャット + ピン留めチャット 貼り付け チャンネル名 新着 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 2e558c6f6..19df1c39a 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -76,6 +76,9 @@ Cheermoty Ostatnie wiadomości %1$s (%2$s) + + Pierwsza wiadomość + Podwyższony czat Wklej Nazwa kanału Ostatnie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6f5e16200..4d5ffe1a9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -77,6 +77,9 @@ Cheermotes Mensagens recentes %1$s (%2$s) + + Primeira mensagem + Mensagem elevada Colar Nome do canal Recente diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index f8f5aeed2..6efc24215 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -77,6 +77,9 @@ Cheermotes Mensagens recentes %1$s (%2$s) + + Primeira mensagem + Mensagem elevada Colar Nome do canal Recente diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 5557e37aa..d2acc9d4f 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -77,6 +77,9 @@ Cheermote-ы Последние сообщения %1$s (%2$s) + + Первое сообщение + Выделенное сообщение Вставить Имя канала Недавние diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 13a7e5517..2dc9c86b2 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -68,6 +68,9 @@ Cheermotovi Nedavne poruke %1$s (%2$s) + + Прва порука + Истакнута порука Paste Naziv kanala Pretplatnici diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index ce18629be..6e4dc1161 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -76,6 +76,9 @@ Cheermote\'lar Son mesajlar %1$s (%2$s) + + İlk mesaj + Yükseltilmiş mesaj Yapıştır Kanal adı Son kullanılanlar diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 9a0046f2f..58bdd35f0 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -77,6 +77,9 @@ Cheermote-и Останні повідомлення %1$s (%2$s) + + Перше повідомлення + Піднесене повідомлення Вставити Ім\'я каналу Останні diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f67246910..5f89ad0a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,9 @@ Cheermotes Recent Messages %1$s (%2$s) + + First Time Chat + Elevated Chat Paste Channel name Recent From 5530c1cae93045fe5a3744acb1e00da49375e991 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 20:43:14 +0100 Subject: [PATCH 102/349] fix(lint): Convert mod duration strings to plurals for proper i18n grammar --- .../data/twitch/message/ModerationMessage.kt | 4 ++-- app/src/main/res/values-be-rBY/strings.xml | 14 ++++++++++++-- app/src/main/res/values-ca/strings.xml | 12 ++++++++++-- app/src/main/res/values-cs/strings.xml | 14 ++++++++++++-- app/src/main/res/values-de-rDE/strings.xml | 10 ++++++++-- app/src/main/res/values-en-rAU/strings.xml | 10 ++++++++-- app/src/main/res/values-en-rGB/strings.xml | 10 ++++++++-- app/src/main/res/values-en/strings.xml | 10 ++++++++-- app/src/main/res/values-es-rES/strings.xml | 12 ++++++++++-- app/src/main/res/values-fi-rFI/strings.xml | 10 ++++++++-- app/src/main/res/values-fr-rFR/strings.xml | 12 ++++++++++-- app/src/main/res/values-hu-rHU/strings.xml | 10 ++++++++-- app/src/main/res/values-it/strings.xml | 12 ++++++++++-- app/src/main/res/values-ja-rJP/strings.xml | 8 ++++++-- app/src/main/res/values-pl-rPL/strings.xml | 14 ++++++++++++-- app/src/main/res/values-pt-rBR/strings.xml | 12 ++++++++++-- app/src/main/res/values-pt-rPT/strings.xml | 12 ++++++++++-- app/src/main/res/values-ru-rRU/strings.xml | 14 ++++++++++++-- app/src/main/res/values-sr/strings.xml | 12 ++++++++++-- app/src/main/res/values-tr-rTR/strings.xml | 10 ++++++++-- app/src/main/res/values-uk-rUA/strings.xml | 14 ++++++++++++-- app/src/main/res/values/strings.xml | 10 ++++++++-- 22 files changed, 202 insertions(+), 44 deletions(-) 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 a78762a2c..358022bcc 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 @@ -174,7 +174,7 @@ data class ModerationMessage( Action.Followers -> when (val mins = durationInt?.takeIf { it > 0 }) { null -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creator)) - else -> TextResource.Res(R.string.mod_followers_on_duration, persistentListOf(creator, mins)) + else -> TextResource.PluralRes(R.plurals.mod_followers_on_duration, mins, persistentListOf(creator, mins)) } Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, persistentListOf(creator)) @@ -183,7 +183,7 @@ data class ModerationMessage( Action.Slow -> when (val secs = durationInt) { null -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creator)) - else -> TextResource.Res(R.string.mod_slow_on_duration, persistentListOf(creator, secs)) + else -> TextResource.PluralRes(R.plurals.mod_slow_on_duration, secs, persistentListOf(creator, secs)) } Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, persistentListOf(creator)) diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 4e46fcbcb..6ef39d1c1 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -510,12 +510,22 @@ %1$s уключыў рэжым толькі эмоцыі %1$s выключыў рэжым толькі эмоцыі %1$s уключыў рэжым толькі для падпісчыкаў канала - %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвілін) + + %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвіліну) + %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвіліны) + %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвілін) + %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвілін) + %1$s выключыў рэжым толькі для падпісчыкаў канала %1$s уключыў рэжым унікальнага чату %1$s выключыў рэжым унікальнага чату %1$s уключыў павольны рэжым - %1$s уключыў павольны рэжым (%2$d секунд) + + %1$s уключыў павольны рэжым (%2$d секунду) + %1$s уключыў павольны рэжым (%2$d секунды) + %1$s уключыў павольны рэжым (%2$d секунд) + %1$s уключыў павольны рэжым (%2$d секунд) + %1$s выключыў павольны рэжым %1$s уключыў рэжым толькі для падпісчыкаў %1$s выключыў рэжым толькі для падпісчыкаў diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9b4ddffb9..f9b926561 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -397,12 +397,20 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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$d minuts) + + %1$s ha activat el mode followers-only (%2$d minut) + %1$s ha activat el mode followers-only (%2$d minuts) + %1$s ha activat el mode followers-only (%2$d minuts) + %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$d segons) + + %1$s ha activat el mode slow (%2$d segon) + %1$s ha activat el mode slow (%2$d segons) + %1$s ha activat el mode slow (%2$d segons) + %1$s ha desactivat el mode slow %1$s ha activat el mode subscribers-only %1$s ha desactivat el mode subscribers-only diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7fc0f2217..9ea40b1db 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -511,12 +511,22 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %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$d minut) + + %1$s zapnul/a režim pouze pro sledující (%2$d minutu) + %1$s zapnul/a režim pouze pro sledující (%2$d minuty) + %1$s zapnul/a režim pouze pro sledující (%2$d minut) + %1$s zapnul/a režim pouze pro sledující (%2$d minut) + %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$d sekund) + + %1$s zapnul/a pomalý režim (%2$d sekundu) + %1$s zapnul/a pomalý režim (%2$d sekundy) + %1$s zapnul/a pomalý režim (%2$d sekund) + %1$s zapnul/a pomalý režim (%2$d sekund) + %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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 49c324b7c..305de3403 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -523,12 +523,18 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %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$d Minuten) + + %1$s hat den Followers-only-Modus aktiviert (%2$d Minute) + %1$s hat den Followers-only-Modus aktiviert (%2$d Minuten) + %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$d Sekunden) + + %1$s hat den Slow-Modus aktiviert (%2$d Sekunde) + %1$s hat den Slow-Modus aktiviert (%2$d Sekunden) + %1$s hat den Slow-Modus deaktiviert %1$s hat den Subscribers-only-Modus aktiviert %1$s hat den Subscribers-only-Modus deaktiviert diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index fa2be7802..8ddfd0c33 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -334,12 +334,18 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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$d minutes) + + %1$s turned on followers-only mode (%2$d minute) + %1$s turned on followers-only mode (%2$d minutes) + %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$d seconds) + + %1$s turned on slow mode (%2$d second) + %1$s turned on slow mode (%2$d seconds) + %1$s turned off slow mode %1$s turned on subscribers-only mode %1$s turned off subscribers-only mode diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index da5c74e58..37024436b 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -335,12 +335,18 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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$d minutes) + + %1$s turned on followers-only mode (%2$d minute) + %1$s turned on followers-only mode (%2$d minutes) + %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$d seconds) + + %1$s turned on slow mode (%2$d second) + %1$s turned on slow mode (%2$d seconds) + %1$s turned off slow mode %1$s turned on subscribers-only mode %1$s turned off subscribers-only mode diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b4a823b21..6b3911809 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -517,12 +517,18 @@ %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$d minutes) + + %1$s turned on followers-only mode (%2$d minute) + %1$s turned on followers-only mode (%2$d minutes) + %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$d seconds) + + %1$s turned on slow mode (%2$d second) + %1$s turned on slow mode (%2$d seconds) + %1$s turned off slow mode %1$s turned on subscribers-only mode %1$s turned off subscribers-only mode diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 6ed8fe0ba..144ae9706 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -526,12 +526,20 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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$d minutos) + + %1$s activó el modo followers-only (%2$d minuto) + %1$s activó el modo followers-only (%2$d minutos) + %1$s activó el modo followers-only (%2$d minutos) + %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$d segundos) + + %1$s activó el modo slow (%2$d segundo) + %1$s activó el modo slow (%2$d segundos) + %1$s activó el modo slow (%2$d segundos) + %1$s desactivó el modo slow %1$s activó el modo subscribers-only %1$s desactivó el modo subscribers-only diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index b67dfc348..142204d25 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -360,12 +360,18 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %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$d minuuttia) + + %1$s otti käyttöön vain seuraajat -tilan (%2$d minuutti) + %1$s otti käyttöön vain seuraajat -tilan (%2$d minuuttia) + %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$d sekuntia) + + %1$s otti käyttöön hitaan tilan (%2$d sekunti) + %1$s otti käyttöön hitaan tilan (%2$d sekuntia) + %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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 7cb7fbeb6..3c9f0e4d8 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -510,12 +510,20 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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$d minutes) + + %1$s a activé le mode followers-only (%2$d minute) + %1$s a activé le mode followers-only (%2$d minutes) + %1$s a activé le mode followers-only (%2$d minutes) + %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$d secondes) + + %1$s a activé le mode slow (%2$d seconde) + %1$s a activé le mode slow (%2$d secondes) + %1$s a activé le mode slow (%2$d secondes) + %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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 76824eae8..ec36c025b 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -501,12 +501,18 @@ %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$d perc) + + %1$s bekapcsolta a csak követők módot (%2$d perc) + %1$s bekapcsolta a csak követők módot (%2$d perc) + %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$d másodperc) + + %1$s bekapcsolta a lassú módot (%2$d másodperc) + %1$s bekapcsolta a lassú módot (%2$d másodperc) + %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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4ad6f0056..3b30bff47 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -493,12 +493,20 @@ %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$d minuti) + + %1$s ha attivato la modalità followers-only (%2$d minuto) + %1$s ha attivato la modalità followers-only (%2$d minuti) + %1$s ha attivato la modalità followers-only (%2$d minuti) + %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$d secondi) + + %1$s ha attivato la modalità slow (%2$d secondo) + %1$s ha attivato la modalità slow (%2$d secondi) + %1$s ha attivato la modalità slow (%2$d secondi) + %1$s ha disattivato la modalità slow %1$s ha attivato la modalità subscribers-only %1$s ha disattivato la modalità subscribers-only diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 5a118a716..a26feb41f 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -488,12 +488,16 @@ %1$sがエモート限定モードをオンにしました %1$sがエモート限定モードをオフにしました %1$sがフォロワー限定モードをオンにしました - %1$sがフォロワー限定モードをオンにしました (%2$d分) + + %1$sがフォロワー限定モードをオンにしました (%2$d分) + %1$sがフォロワー限定モードをオフにしました %1$sがユニークチャットモードをオンにしました %1$sがユニークチャットモードをオフにしました %1$sがスローモードをオンにしました - %1$sがスローモードをオンにしました (%2$d秒) + + %1$sがスローモードをオンにしました (%2$d秒) + %1$sがスローモードをオフにしました %1$sがサブスクライバー限定モードをオンにしました %1$sがサブスクライバー限定モードをオフにしました diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 19df1c39a..502cdfe0d 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -529,12 +529,22 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %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$d minut) + + %1$s włączył/a tryb tylko dla obserwujących (%2$d minutę) + %1$s włączył/a tryb tylko dla obserwujących (%2$d minuty) + %1$s włączył/a tryb tylko dla obserwujących (%2$d minut) + %1$s włączył/a tryb tylko dla obserwujących (%2$d minut) + %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$d sekund) + + %1$s włączył/a tryb powolny (%2$d sekundę) + %1$s włączył/a tryb powolny (%2$d sekundy) + %1$s włączył/a tryb powolny (%2$d sekund) + %1$s włączył/a tryb powolny (%2$d sekund) + %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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4d5ffe1a9..772ea082d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -505,12 +505,20 @@ %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$d minutos) + + %1$s ativou o modo somente seguidores (%2$d minuto) + %1$s ativou o modo somente seguidores (%2$d minutos) + %1$s ativou o modo somente seguidores (%2$d minutos) + %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$d segundos) + + %1$s ativou o modo lento (%2$d segundo) + %1$s ativou o modo lento (%2$d segundos) + %1$s ativou o modo lento (%2$d segundos) + %1$s desativou o modo lento %1$s ativou o modo somente inscritos %1$s desativou o modo somente inscritos diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 6efc24215..fe8241938 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -495,12 +495,20 @@ %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$d minutos) + + %1$s ativou o modo apenas seguidores (%2$d minuto) + %1$s ativou o modo apenas seguidores (%2$d minutos) + %1$s ativou o modo apenas seguidores (%2$d minutos) + %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$d segundos) + + %1$s ativou o modo lento (%2$d segundo) + %1$s ativou o modo lento (%2$d segundos) + %1$s ativou o modo lento (%2$d segundos) + %1$s desativou o modo lento %1$s ativou o modo apenas subscritores %1$s desativou o modo apenas subscritores diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index d2acc9d4f..3362a31ef 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -515,12 +515,22 @@ %1$s включил режим только эмоции %1$s выключил режим только эмоции %1$s включил режим только для подписчиков канала - %1$s включил режим только для подписчиков канала (%2$d минут) + + %1$s включил режим только для подписчиков канала (%2$d минуту) + %1$s включил режим только для подписчиков канала (%2$d минуты) + %1$s включил режим только для подписчиков канала (%2$d минут) + %1$s включил режим только для подписчиков канала (%2$d минут) + %1$s выключил режим только для подписчиков канала %1$s включил режим уникального чата %1$s выключил режим уникального чата %1$s включил медленный режим - %1$s включил медленный режим (%2$d секунд) + + %1$s включил медленный режим (%2$d секунду) + %1$s включил медленный режим (%2$d секунды) + %1$s включил медленный режим (%2$d секунд) + %1$s включил медленный режим (%2$d секунд) + %1$s выключил медленный режим %1$s включил режим только для подписчиков %1$s выключил режим только для подписчиков diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 2dc9c86b2..ceb0ee9aa 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -303,12 +303,20 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%1$s је укључио emote-only режим %1$s је искључио emote-only режим %1$s је укључио followers-only режим - %1$s је укључио followers-only режим (%2$d минута) + + %1$s је укључио followers-only режим (%2$d минут) + %1$s је укључио followers-only режим (%2$d минута) + %1$s је укључио followers-only режим (%2$d минута) + %1$s је искључио followers-only режим %1$s је укључио unique-chat режим %1$s је искључио unique-chat режим %1$s је укључио спори режим - %1$s је укључио спори режим (%2$d секунди) + + %1$s је укључио спори режим (%2$d секунду) + %1$s је укључио спори режим (%2$d секунде) + %1$s је укључио спори режим (%2$d секунди) + %1$s је искључио спори режим %1$s је укључио subscribers-only режим %1$s је искључио subscribers-only режим diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 6e4dc1161..ea963b465 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -522,12 +522,18 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %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$d dakika) + + %1$s yalnızca takipçiler modunu açtı (%2$d dakika) + %1$s yalnızca takipçiler modunu açtı (%2$d dakika) + %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$d saniye) + + %1$s yavaş modu açtı (%2$d saniye) + %1$s yavaş modu açtı (%2$d saniye) + %1$s yavaş modu kapattı %1$s yalnızca aboneler modunu açtı %1$s yalnızca aboneler modunu kapattı diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 58bdd35f0..4e36e109a 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -512,12 +512,22 @@ %1$s увімкнув режим лише емоції %1$s вимкнув режим лише емоції %1$s увімкнув режим лише для підписників каналу - %1$s увімкнув режим лише для підписників каналу (%2$d хвилин) + + %1$s увімкнув режим лише для підписників каналу (%2$d хвилину) + %1$s увімкнув режим лише для підписників каналу (%2$d хвилини) + %1$s увімкнув режим лише для підписників каналу (%2$d хвилин) + %1$s увімкнув режим лише для підписників каналу (%2$d хвилин) + %1$s вимкнув режим лише для підписників каналу %1$s увімкнув режим унікального чату %1$s вимкнув режим унікального чату %1$s увімкнув повільний режим - %1$s увімкнув повільний режим (%2$d секунд) + + %1$s увімкнув повільний режим (%2$d секунду) + %1$s увімкнув повільний режим (%2$d секунди) + %1$s увімкнув повільний режим (%2$d секунд) + %1$s увімкнув повільний режим (%2$d секунд) + %1$s вимкнув повільний режим %1$s увімкнув режим лише для підписників %1$s вимкнув режим лише для підписників diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f89ad0a4..1827a5769 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -172,12 +172,18 @@ %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$d minutes) + + %1$s turned on followers-only mode (%2$d minute) + %1$s turned on followers-only mode (%2$d minutes) + %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$d seconds) + + %1$s turned on slow mode (%2$d second) + %1$s turned on slow mode (%2$d seconds) + %1$s turned off slow mode %1$s turned on subscribers-only mode %1$s turned off subscribers-only mode From faf98845adb667f6283a15b7e2b286baac9af60c Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 21:48:03 +0100 Subject: [PATCH 103/349] feat(compose): Add room state preset chips, fix duration formatting, and cache history colors --- .../data/repo/chat/RecentMessagesHandler.kt | 4 + .../data/twitch/message/ModerationMessage.kt | 32 +- .../main/compose/dialogs/RoomStateDialog.kt | 347 +++++++++++++----- .../com/flxrs/dankchat/utils/DateTimeUtils.kt | 20 + app/src/main/res/values-be-rBY/strings.xml | 54 ++- app/src/main/res/values-ca/strings.xml | 47 ++- app/src/main/res/values-cs/strings.xml | 54 ++- app/src/main/res/values-de-rDE/strings.xml | 40 +- app/src/main/res/values-en-rAU/strings.xml | 40 +- app/src/main/res/values-en-rGB/strings.xml | 40 +- app/src/main/res/values-en/strings.xml | 40 +- app/src/main/res/values-es-rES/strings.xml | 47 ++- app/src/main/res/values-fi-rFI/strings.xml | 40 +- app/src/main/res/values-fr-rFR/strings.xml | 47 ++- app/src/main/res/values-hu-rHU/strings.xml | 40 +- app/src/main/res/values-it/strings.xml | 47 ++- app/src/main/res/values-ja-rJP/strings.xml | 33 +- app/src/main/res/values-pl-rPL/strings.xml | 54 ++- app/src/main/res/values-pt-rBR/strings.xml | 47 ++- app/src/main/res/values-pt-rPT/strings.xml | 47 ++- app/src/main/res/values-ru-rRU/strings.xml | 54 ++- app/src/main/res/values-sr/strings.xml | 47 ++- app/src/main/res/values-tr-rTR/strings.xml | 40 +- app/src/main/res/values-uk-rUA/strings.xml | 54 ++- app/src/main/res/values/strings.xml | 42 ++- .../flxrs/dankchat/utils/DateTimeUtilsTest.kt | 78 ++++ 26 files changed, 1149 insertions(+), 286 deletions(-) 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 index 646b8fae2..f59bbca98 100644 --- 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 @@ -33,6 +33,7 @@ class RecentMessagesHandler( private val recentMessagesApiClient: RecentMessagesApiClient, private val messageProcessor: MessageProcessor, private val chatMessageRepository: ChatMessageRepository, + private val usersRepository: UsersRepository, ) { private val loadedChannels = mutableSetOf() @@ -95,6 +96,9 @@ class RecentMessagesHandler( if (message is PrivMessage) { val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() userSuggestions += message.name.lowercase() to userForSuggestion + if (message.color != Message.DEFAULT_COLOR) { + usersRepository.cacheUserColor(message.name, message.color) + } } val importance = when { 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 358022bcc..fed106343 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 @@ -90,6 +90,34 @@ data class ModerationMessage( } } + private 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)) }) + } + + private 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 getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): TextResource { val creator = creatorUserDisplay.toString() val target = targetUserDisplay.toString() @@ -174,7 +202,7 @@ data class ModerationMessage( Action.Followers -> when (val mins = durationInt?.takeIf { it > 0 }) { null -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creator)) - else -> TextResource.PluralRes(R.plurals.mod_followers_on_duration, mins, persistentListOf(creator, mins)) + else -> TextResource.Res(R.string.mod_followers_on_duration, persistentListOf(creator, formatMinutesDuration(mins))) } Action.FollowersOff -> TextResource.Res(R.string.mod_followers_off, persistentListOf(creator)) @@ -183,7 +211,7 @@ data class ModerationMessage( Action.Slow -> when (val secs = durationInt) { null -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creator)) - else -> TextResource.PluralRes(R.plurals.mod_slow_on_duration, secs, persistentListOf(creator, secs)) + else -> TextResource.Res(R.string.mod_slow_on_duration, persistentListOf(creator, formatSecondsDuration(secs))) } Action.SlowOff -> TextResource.Res(R.string.mod_slow_off, persistentListOf(creator)) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt index cd94660a9..4c5e17478 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt @@ -1,6 +1,13 @@ package com.flxrs.dankchat.main.compose.dialogs +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.fillMaxWidth @@ -10,9 +17,11 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -29,6 +38,7 @@ import androidx.compose.ui.text.input.KeyboardType 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 data class ParameterDialogConfig(val titleRes: Int, val hintRes: Int, val defaultValue: String, val commandPrefix: String) @@ -37,6 +47,30 @@ private enum class ParameterDialogType { FOLLOWER_MODE } +private val SLOW_MODE_PRESETS = listOf(3, 5, 10, 20, 30, 60, 120) +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 RoomStateDialog( @@ -44,7 +78,9 @@ fun RoomStateDialog( onSendCommand: (String) -> Unit, onDismiss: () -> Unit, ) { + var presetsView by remember { mutableStateOf(null) } var parameterDialog by remember { mutableStateOf(null) } + var showSheet by remember { mutableStateOf(true) } parameterDialog?.let { type -> val (titleRes, hintRes, defaultValue, commandPrefix) = when (type) { @@ -66,7 +102,10 @@ fun RoomStateDialog( var inputValue by remember(type) { mutableStateOf(defaultValue) } AlertDialog( - onDismissRequest = { parameterDialog = null }, + onDismissRequest = { + parameterDialog = null + onDismiss() + }, title = { Text(stringResource(titleRes)) }, text = { OutlinedTextField( @@ -88,105 +127,245 @@ fun RoomStateDialog( } }, dismissButton = { - TextButton(onClick = { parameterDialog = null }) { + TextButton(onClick = { + parameterDialog = null + onDismiss() + }) { Text(stringResource(R.string.dialog_cancel)) } } ) } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + if (showSheet) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + AnimatedContent( + targetState = presetsView, + transitionSpec = { + when { + targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + } + }, + label = "RoomStateContent" + ) { currentView -> + when (currentView) { + null -> RoomStateModeChips( + roomState = roomState, + onSendCommand = onSendCommand, + onShowPresets = { presetsView = it }, + onDismiss = onDismiss, + ) + + ParameterDialogType.SLOW_MODE -> 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 = { + parameterDialog = ParameterDialogType.SLOW_MODE + showSheet = false + }, + ) + + ParameterDialogType.FOLLOWER_MODE -> FollowerPresetChips( + onPresetClick = { preset -> + onSendCommand("/followers ${preset.commandArg}") + onDismiss() + }, + onCustomClick = { + parameterDialog = ParameterDialogType.FOLLOWER_MODE + showSheet = false + }, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RoomStateModeChips( + roomState: RoomState?, + onSendCommand: (String) -> Unit, + onShowPresets: (ParameterDialogType) -> Unit, + onDismiss: () -> Unit, +) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + 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 = { + if (isSlowMode) { + onSendCommand("/slowoff") + onDismiss() + } else { + onShowPresets(ParameterDialogType.SLOW_MODE) + } + }, + 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 = { + if (isFollowerOnly) { + onSendCommand("/followersoff") + onDismiss() + } else { + onShowPresets(ParameterDialogType.FOLLOWER_MODE) + } + }, + 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( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), 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, - ) + presets.forEach { value -> + AssistChip( + onClick = { onPresetClick(value) }, + label = { Text(formatLabel(value)) }, + ) + } - 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, + AssistChip( + onClick = onCustomClick, + label = { Text(stringResource(R.string.room_state_preset_custom)) }, ) + } + } +} - val isSlowMode = roomState?.isSlowMode == true - val slowModeWaitTime = roomState?.slowModeWaitTime - FilterChip( - selected = isSlowMode, - onClick = { - if (isSlowMode) { - onSendCommand("/slowoff") - onDismiss() - } else { - parameterDialog = ParameterDialogType.SLOW_MODE - } - }, - label = { - val label = stringResource(R.string.room_state_slow_mode) - Text(if (isSlowMode && slowModeWaitTime != null) "$label (${slowModeWaitTime}s)" else label) - }, - leadingIcon = if (isSlowMode) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else null, - ) +@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), + ) - 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, - ) + 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)) }, + ) + } - val isFollowerOnly = roomState?.isFollowMode == true - val followerDuration = roomState?.followerModeDuration - FilterChip( - selected = isFollowerOnly, - onClick = { - if (isFollowerOnly) { - onSendCommand("/followersoff") - onDismiss() - } else { - parameterDialog = ParameterDialogType.FOLLOWER_MODE - } - }, - label = { - val label = stringResource(R.string.room_state_follower_only) - Text(if (isFollowerOnly && followerDuration != null && followerDuration > 0) "$label (${followerDuration}m)" else label) - }, - leadingIcon = if (isFollowerOnly) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else null, + AssistChip( + onClick = onCustomClick, + label = { Text(stringResource(R.string.room_state_preset_custom)) }, ) } } 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..0054d9b6d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt @@ -73,6 +73,26 @@ object DateTimeUtils { 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() diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 6ef39d1c1..d68773381 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -80,6 +80,38 @@ Першае паведамленне Павышанае паведамленне + + %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 Уставіць Назва канала Нядаўнія @@ -510,22 +542,12 @@ %1$s уключыў рэжым толькі эмоцыі %1$s выключыў рэжым толькі эмоцыі %1$s уключыў рэжым толькі для падпісчыкаў канала - - %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвіліну) - %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвіліны) - %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвілін) - %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$d хвілін) - + %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$s) %1$s выключыў рэжым толькі для падпісчыкаў канала %1$s уключыў рэжым унікальнага чату %1$s выключыў рэжым унікальнага чату %1$s уключыў павольны рэжым - - %1$s уключыў павольны рэжым (%2$d секунду) - %1$s уключыў павольны рэжым (%2$d секунды) - %1$s уключыў павольны рэжым (%2$d секунд) - %1$s уключыў павольны рэжым (%2$d секунд) - + %1$s уключыў павольны рэжым (%2$s) %1$s выключыў павольны рэжым %1$s уключыў рэжым толькі для падпісчыкаў %1$s выключыў рэжым толькі для падпісчыкаў @@ -560,6 +582,14 @@ Павольны рэжым Унікальны чат (R9K) Толькі фалаверы + Іншае + Любы + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Дадайце канал, каб пачаць размову diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f9b926561..ca647025e 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -76,6 +76,33 @@ Primer missatge Missatge destacat + + %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 Recent @@ -397,20 +424,12 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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$d minut) - %1$s ha activat el mode followers-only (%2$d minuts) - %1$s ha activat el mode followers-only (%2$d minuts) - + %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$d segon) - %1$s ha activat el mode slow (%2$d segons) - %1$s ha activat el mode slow (%2$d segons) - + %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 @@ -444,6 +463,14 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Mode lent Xat únic (R9K) Només seguidors + Personalitzat + Qualsevol + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Afegeix un canal per començar a xatejar diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9ea40b1db..6aabc7421 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -80,6 +80,38 @@ První zpráva Zvýrazněná zpráva + + %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 Nedávné @@ -511,22 +543,12 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %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$d minutu) - %1$s zapnul/a režim pouze pro sledující (%2$d minuty) - %1$s zapnul/a režim pouze pro sledující (%2$d minut) - %1$s zapnul/a režim pouze pro sledující (%2$d minut) - + %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$d sekundu) - %1$s zapnul/a pomalý režim (%2$d sekundy) - %1$s zapnul/a pomalý režim (%2$d sekund) - %1$s zapnul/a pomalý režim (%2$d sekund) - + %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 @@ -561,6 +583,14 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Pomalý režim Unikátní chat (R9K) Pouze sledující + Vlastní + Jakýkoliv + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Přidejte kanál a začněte chatovat diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 305de3403..c059d8b47 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -80,6 +80,28 @@ Erste Nachricht Hervorgehobene Nachricht + + %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 Zuletzt @@ -523,18 +545,12 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %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$d Minute) - %1$s hat den Followers-only-Modus aktiviert (%2$d Minuten) - + %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$d Sekunde) - %1$s hat den Slow-Modus aktiviert (%2$d Sekunden) - + %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 @@ -567,6 +583,14 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Langsamer Modus Einzigartiger Chat (R9K) Nur Follower + Benutzerdefiniert + Alle + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Füge einen Kanal hinzu, um zu chatten diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 8ddfd0c33..0babc6680 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -76,6 +76,28 @@ First Time Chat Elevated Chat + + %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 Recent @@ -334,18 +356,12 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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$d minute) - %1$s turned on followers-only mode (%2$d minutes) - + %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$d second) - %1$s turned on slow mode (%2$d seconds) - + %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 @@ -375,6 +391,14 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Slow mode Unique chat (R9K) Follower only + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Add a channel to start chatting No recent emotes Show stream diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 37024436b..eea1e2e57 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -76,6 +76,28 @@ First Time Chat Elevated Chat + + %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 Recent @@ -335,18 +357,12 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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$d minute) - %1$s turned on followers-only mode (%2$d minutes) - + %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$d second) - %1$s turned on slow mode (%2$d seconds) - + %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 @@ -376,6 +392,14 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Slow mode Unique chat (R9K) Follower only + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Add a channel to start chatting No recent emotes Show stream diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 6b3911809..77fca98bf 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -80,6 +80,28 @@ First Time Chat Elevated Chat + + %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 Recent @@ -517,18 +539,12 @@ %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$d minute) - %1$s turned on followers-only mode (%2$d minutes) - + %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$d second) - %1$s turned on slow mode (%2$d seconds) - + %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 @@ -561,6 +577,14 @@ Slow mode Unique chat (R9K) Follower only + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Add a channel to start chatting diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 144ae9706..459950bde 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -79,6 +79,33 @@ Primer mensaje Mensaje destacado + + %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 Reciente @@ -526,20 +553,12 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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$d minuto) - %1$s activó el modo followers-only (%2$d minutos) - %1$s activó el modo followers-only (%2$d minutos) - + %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$d segundo) - %1$s activó el modo slow (%2$d segundos) - %1$s activó el modo slow (%2$d segundos) - + %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 @@ -573,6 +592,14 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Modo lento Chat único (R9K) Solo seguidores + Personalizado + Cualquiera + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Añade un canal para empezar a chatear diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 142204d25..a7fd6594c 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -79,6 +79,28 @@ Ensimmäinen viesti Korostettu viesti + + %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 Viimeisimmät @@ -360,18 +382,12 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %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$d minuutti) - %1$s otti käyttöön vain seuraajat -tilan (%2$d minuuttia) - + %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$d sekunti) - %1$s otti käyttöön hitaan tilan (%2$d sekuntia) - + %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 @@ -404,6 +420,14 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Hidas tila Ainutlaatuinen chat (R9K) Vain seuraajat + Mukautettu + Mikä tahansa + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Lisää kanava aloittaaksesi keskustelun diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 3c9f0e4d8..ddb68efae 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -80,6 +80,33 @@ Premier message Message mis en avant + + %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 Récentes @@ -510,20 +537,12 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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$d minute) - %1$s a activé le mode followers-only (%2$d minutes) - %1$s a activé le mode followers-only (%2$d minutes) - + %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$d seconde) - %1$s a activé le mode slow (%2$d secondes) - %1$s a activé le mode slow (%2$d secondes) - + %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 @@ -557,6 +576,14 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Mode lent Chat unique (R9K) Abonnés uniquement + Personnalisé + Tous + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Ajoutez une chaîne pour commencer à discuter diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index ec36c025b..86a4aa351 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -80,6 +80,28 @@ Első üzenet Kiemelt üzenet + + %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 Legutóbbi @@ -501,18 +523,12 @@ %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$d perc) - %1$s bekapcsolta a csak követők módot (%2$d perc) - + %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$d másodperc) - %1$s bekapcsolta a lassú módot (%2$d másodperc) - + %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 @@ -545,6 +561,14 @@ Lassú mód Egyedi chat (R9K) Csak követők + Egyéni + Bármely + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Adj hozzá egy csatornát a csevegéshez diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3b30bff47..031442931 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -79,6 +79,33 @@ Primo messaggio Messaggio elevato + + %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 Recente @@ -493,20 +520,12 @@ %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$d minuto) - %1$s ha attivato la modalità followers-only (%2$d minuti) - %1$s ha attivato la modalità followers-only (%2$d minuti) - + %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$d secondo) - %1$s ha attivato la modalità slow (%2$d secondi) - %1$s ha attivato la modalità slow (%2$d secondi) - + %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 @@ -540,6 +559,14 @@ Modalità lenta Chat unica (R9K) Solo follower + Personalizzato + Qualsiasi + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Aggiungi un canale per iniziare a chattare diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index a26feb41f..b0181d03c 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -79,6 +79,23 @@ 初めてのチャット ピン留めチャット + + %1$d秒 + + + %1$d分 + + + %1$d時間 + + + %1$d日 + + + %1$d週間 + + %1$s %2$s + %1$s %2$s %3$s 貼り付け チャンネル名 新着 @@ -488,16 +505,12 @@ %1$sがエモート限定モードをオンにしました %1$sがエモート限定モードをオフにしました %1$sがフォロワー限定モードをオンにしました - - %1$sがフォロワー限定モードをオンにしました (%2$d分) - + %1$sがフォロワー限定モードをオンにしました (%2$s) %1$sがフォロワー限定モードをオフにしました %1$sがユニークチャットモードをオンにしました %1$sがユニークチャットモードをオフにしました %1$sがスローモードをオンにしました - - %1$sがスローモードをオンにしました (%2$d秒) - + %1$sがスローモードをオンにしました (%2$s) %1$sがスローモードをオフにしました %1$sがサブスクライバー限定モードをオンにしました %1$sがサブスクライバー限定モードをオフにしました @@ -529,6 +542,14 @@ スローモード ユニークチャット (R9K) フォロワーのみ + カスタム + すべて + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo チャンネルを追加してチャットを始めましょう diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 502cdfe0d..c1e44e5fb 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -79,6 +79,38 @@ Pierwsza wiadomość Podwyższony czat + + %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 Ostatnie @@ -529,22 +561,12 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %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$d minutę) - %1$s włączył/a tryb tylko dla obserwujących (%2$d minuty) - %1$s włączył/a tryb tylko dla obserwujących (%2$d minut) - %1$s włączył/a tryb tylko dla obserwujących (%2$d minut) - + %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$d sekundę) - %1$s włączył/a tryb powolny (%2$d sekundy) - %1$s włączył/a tryb powolny (%2$d sekund) - %1$s włączył/a tryb powolny (%2$d sekund) - + %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 @@ -579,6 +601,14 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Tryb powolny Unikalny czat (R9K) Tylko obserwujący + Własne + Dowolny + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Dodaj kanał, aby zacząć czatować diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 772ea082d..9c233dc05 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -80,6 +80,33 @@ Primeira mensagem Mensagem elevada + + %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 Recente @@ -505,20 +532,12 @@ %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$d minuto) - %1$s ativou o modo somente seguidores (%2$d minutos) - %1$s ativou o modo somente seguidores (%2$d minutos) - + %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$d segundo) - %1$s ativou o modo lento (%2$d segundos) - %1$s ativou o modo lento (%2$d segundos) - + %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 @@ -552,6 +571,14 @@ Modo lento Chat único (R9K) Apenas seguidores + Personalizado + Qualquer + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Adicione um canal para começar a conversar diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index fe8241938..56fd2c3e7 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -80,6 +80,33 @@ Primeira mensagem Mensagem elevada + + %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 Recente @@ -495,20 +522,12 @@ %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$d minuto) - %1$s ativou o modo apenas seguidores (%2$d minutos) - %1$s ativou o modo apenas seguidores (%2$d minutos) - + %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$d segundo) - %1$s ativou o modo lento (%2$d segundos) - %1$s ativou o modo lento (%2$d segundos) - + %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 @@ -542,6 +561,14 @@ Modo lento Chat único (R9K) Apenas seguidores + Personalizado + Qualquer + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Adicione um canal para começar a conversar diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3362a31ef..6f7fa6948 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -80,6 +80,38 @@ Первое сообщение Выделенное сообщение + + %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 Вставить Имя канала Недавние @@ -515,22 +547,12 @@ %1$s включил режим только эмоции %1$s выключил режим только эмоции %1$s включил режим только для подписчиков канала - - %1$s включил режим только для подписчиков канала (%2$d минуту) - %1$s включил режим только для подписчиков канала (%2$d минуты) - %1$s включил режим только для подписчиков канала (%2$d минут) - %1$s включил режим только для подписчиков канала (%2$d минут) - + %1$s включил режим только для подписчиков канала (%2$s) %1$s выключил режим только для подписчиков канала %1$s включил режим уникального чата %1$s выключил режим уникального чата %1$s включил медленный режим - - %1$s включил медленный режим (%2$d секунду) - %1$s включил медленный режим (%2$d секунды) - %1$s включил медленный режим (%2$d секунд) - %1$s включил медленный режим (%2$d секунд) - + %1$s включил медленный режим (%2$s) %1$s выключил медленный режим %1$s включил режим только для подписчиков %1$s выключил режим только для подписчиков @@ -565,6 +587,14 @@ Медленный режим Уникальный чат (R9K) Только фолловеры + Другое + Любой + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Добавьте канал, чтобы начать общение diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index ceb0ee9aa..2d8dcd421 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -71,6 +71,33 @@ Прва порука Истакнута порука + + %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 @@ -303,20 +330,12 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%1$s је укључио emote-only режим %1$s је искључио emote-only режим %1$s је укључио followers-only режим - - %1$s је укључио followers-only режим (%2$d минут) - %1$s је укључио followers-only режим (%2$d минута) - %1$s је укључио followers-only режим (%2$d минута) - + %1$s је укључио followers-only режим (%2$s) %1$s је искључио followers-only режим %1$s је укључио unique-chat режим %1$s је искључио unique-chat режим %1$s је укључио спори режим - - %1$s је укључио спори режим (%2$d секунду) - %1$s је укључио спори режим (%2$d секунде) - %1$s је укључио спори режим (%2$d секунди) - + %1$s је укључио спори режим (%2$s) %1$s је искључио спори режим %1$s је укључио subscribers-only режим %1$s је искључио subscribers-only режим @@ -350,6 +369,14 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Спори режим Јединствени чат (R9K) Само пратиоци + Прилагођено + Било који + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Додајте канал да бисте почели да ћаскате diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index ea963b465..7c1905937 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -79,6 +79,28 @@ İlk mesaj Yükseltilmiş mesaj + + %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ı Son kullanılanlar @@ -522,18 +544,12 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %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$d dakika) - %1$s yalnızca takipçiler modunu açtı (%2$d dakika) - + %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$d saniye) - %1$s yavaş modu açtı (%2$d saniye) - + %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ı @@ -566,6 +582,14 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Yavaş mod Benzersiz sohbet (R9K) Yalnızca takipçiler + Özel + Herhangi + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Sohbete başlamak için bir kanal ekleyin diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 4e36e109a..67492d32c 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -80,6 +80,38 @@ Перше повідомлення Піднесене повідомлення + + %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 Вставити Ім\'я каналу Останні @@ -512,22 +544,12 @@ %1$s увімкнув режим лише емоції %1$s вимкнув режим лише емоції %1$s увімкнув режим лише для підписників каналу - - %1$s увімкнув режим лише для підписників каналу (%2$d хвилину) - %1$s увімкнув режим лише для підписників каналу (%2$d хвилини) - %1$s увімкнув режим лише для підписників каналу (%2$d хвилин) - %1$s увімкнув режим лише для підписників каналу (%2$d хвилин) - + %1$s увімкнув режим лише для підписників каналу (%2$s) %1$s вимкнув режим лише для підписників каналу %1$s увімкнув режим унікального чату %1$s вимкнув режим унікального чату %1$s увімкнув повільний режим - - %1$s увімкнув повільний режим (%2$d секунду) - %1$s увімкнув повільний режим (%2$d секунди) - %1$s увімкнув повільний режим (%2$d секунд) - %1$s увімкнув повільний режим (%2$d секунд) - + %1$s увімкнув повільний режим (%2$s) %1$s вимкнув повільний режим %1$s увімкнув режим лише для підписників %1$s вимкнув режим лише для підписників @@ -562,6 +584,14 @@ Повільний режим Унікальний чат (R9K) Лише фоловери + Інше + Будь-який + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Додайте канал, щоб почати спілкування diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1827a5769..46b575df8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,30 @@ First Time Chat Elevated Chat + + + %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 Recent @@ -172,18 +196,12 @@ %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$d minute) - %1$s turned on followers-only mode (%2$d minutes) - + %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$d second) - %1$s turned on slow mode (%2$d seconds) - + %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 @@ -272,6 +290,14 @@ Slow mode Unique chat (R9K) Follower only + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo Account Login again Logout 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..0c94c6d48 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt @@ -1,5 +1,11 @@ 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 @@ -102,4 +108,76 @@ internal class DateTimeUtilsTest { val result = DateTimeUtils.durationToSeconds("3s 1h 4d 5m") assertEquals(expected = 349503, actual = result) } + + @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) + } } \ No newline at end of file From ff2af2e1ab50628cb4d2a48bc71a8ab53f8d735f Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 22:31:56 +0100 Subject: [PATCH 104/349] refactor(compose): Extract shared rendering, deduplicate composables, fix stability annotations --- .../dankchat/chat/FullScreenSheetState.kt | 12 - .../flxrs/dankchat/chat/InputSheetState.kt | 12 - .../chat/compose/messages/PrivMessage.kt | 118 +--- .../chat/compose/messages/SystemMessages.kt | 53 +- .../compose/messages/WhisperAndRedemption.kt | 122 +--- .../messages/common/MessageTextRenderer.kt | 131 +++++ .../messages/common/SimpleMessageContainer.kt | 25 +- .../data/repo/chat/ChatChannelProvider.kt | 8 +- .../repo/chat/ChatNotificationRepository.kt | 16 +- .../data/repo/stream/StreamDataRepository.kt | 11 +- .../dankchat/main/compose/ChatInputLayout.kt | 520 ++++++++++-------- .../main/compose/DialogStateViewModel.kt | 4 +- .../main/compose/MainScreenDialogs.kt | 12 - 13 files changed, 499 insertions(+), 545 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/FullScreenSheetState.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/InputSheetState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextRenderer.kt 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/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt index c6e5f4905..1c893ccda 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt @@ -1,10 +1,6 @@ package com.flxrs.dankchat.chat.compose.messages -import android.util.Log -import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.clickable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource @@ -28,32 +24,26 @@ 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.platform.LocalDensity +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.FontFamily 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.em import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import coil3.compose.LocalPlatformContext -import com.flxrs.dankchat.chat.compose.resolve import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.EmoteDimensions -import com.flxrs.dankchat.chat.compose.messages.common.BadgeInlineContent -import com.flxrs.dankchat.chat.compose.EmoteScaling -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.StackedEmote -import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks +import com.flxrs.dankchat.chat.compose.messages.common.MessageTextWithInlineContent +import com.flxrs.dankchat.chat.compose.messages.common.launchCustomTab +import com.flxrs.dankchat.chat.compose.messages.common.timestampSpanStyle import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberNormalizedColor +import com.flxrs.dankchat.chat.compose.resolve import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote @@ -172,7 +162,6 @@ private fun PrivMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val emoteCoordinator = LocalEmoteAnimationCoordinator.current val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) val linkColor = MaterialTheme.colorScheme.primary @@ -194,15 +183,7 @@ private fun PrivMessageText( // Timestamp if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = (fontSize * 0.95f).sp, - color = defaultTextColor, - letterSpacing = (-0.03).em - ) - ) { + withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { append(message.timestamp) append(" ") } @@ -282,74 +263,15 @@ private fun PrivMessageText( } } - // Build inline content providers for SubcomposeLayout - val badgeSize = EmoteScaling.getBadgeSize(fontSize) - val inlineContentProviders: Map Unit> = remember(message.badges, message.emotes, fontSize) { - buildMap Unit> { - // Badge providers - message.badges.forEach { badge -> - put("BADGE_${badge.position}") { - BadgeInlineContent(badge = badge, size = badgeSize) - } - } - - // Emote providers - message.emotes.forEach { emote -> - put("EMOTE_${emote.code}") { - StackedEmote( - emote = emote, - fontSize = fontSize, - emoteCoordinator = emoteCoordinator, - animateGifs = animateGifs, - modifier = Modifier, - onClick = { onEmoteClick(emote.emotes) } - ) - } - } - } - } - - // Compute known dimensions from dimension cache to skip measurement subcomposition - val density = LocalDensity.current - val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { - buildMap { - // Badge dimensions are always known (fixed size) - val badgeSizePx = with(density) { badgeSize.toPx().toInt() } - message.badges.forEach { badge -> - put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) - } - // Emote dimensions from cache - val baseHeight = EmoteScaling.getBaseHeight(fontSize) - val baseHeightPx = with(density) { baseHeight.toPx().toInt() } - message.emotes.forEach { emote -> - val id = "EMOTE_${emote.code}" - if (emote.urls.size == 1) { - val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } else { - val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" - val dims = emoteCoordinator.dimensionCache.get(cacheKey) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } - } - } - } - - // Use SubcomposeLayout to measure inline content, then render text - TextWithMeasuredInlineContent( - text = annotatedString, - inlineContentProviders = inlineContentProviders, - style = TextStyle(fontSize = fontSize.sp), - knownDimensions = knownDimensions, - modifier = Modifier - .fillMaxWidth(), + MessageTextWithInlineContent( + annotatedString = annotatedString, + badges = message.badges, + emotes = message.emotes, + fontSize = fontSize, + animateGifs = animateGifs, interactionSource = interactionSource, + onEmoteClick = onEmoteClick, onTextClick = { offset -> - // Handle username clicks annotatedString.getStringAnnotations("USER", offset, offset) .firstOrNull()?.let { annotation -> val parts = annotation.item.split("|") @@ -362,21 +284,13 @@ private fun PrivMessageText( } } - // Handle URL clicks annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> - try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - .launchUrl(context, annotation.item.toUri()) - } catch (e: Exception) { - Log.e("PrivMessage", "Error launching URL", e) - } + launchCustomTab(context, annotation.item) } }, onTextLongClick = { onMessageLongClick(message.id, message.channel.value, message.fullMessage) - } + }, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt index 9dcdcb7d5..eebc2845a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt @@ -1,11 +1,6 @@ package com.flxrs.dankchat.chat.compose.messages -import android.util.Log -import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -13,23 +8,26 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.ClickableText 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.platform.LocalContext 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 androidx.core.net.toUri import com.flxrs.dankchat.chat.compose.ChatMessageUiState import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.messages.common.SimpleMessageContainer +import com.flxrs.dankchat.chat.compose.messages.common.launchCustomTab +import com.flxrs.dankchat.chat.compose.messages.common.timestampSpanStyle import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberNormalizedColor @@ -99,15 +97,7 @@ fun UserNoticeMessageComposable( buildAnnotatedString { // Timestamp if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = textSize * 0.95f, - color = timestampColor, - letterSpacing = (-0.03).em, - ) - ) { + withStyle(timestampSpanStyle(textSize.value, timestampColor)) { append(message.timestamp) } append(" ") @@ -169,14 +159,7 @@ fun UserNoticeMessageComposable( onClick = { offset -> annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> - try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - .launchUrl(context, annotation.item.toUri()) - } catch (e: Exception) { - Log.e("UserNoticeMessage", "Error launching URL", e) - } + launchCustomTab(context, annotation.item) } } ) @@ -203,6 +186,7 @@ fun DateSeparatorComposable( ) } +@Immutable private data class StyledRange(val start: Int, val length: Int, val color: Color, val bold: Boolean) /** @@ -258,15 +242,7 @@ fun ModerationMessageComposable( buildAnnotatedString { // Timestamp if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = textSize * 0.95f, - color = timestampColor, - letterSpacing = (-0.03).em, - ) - ) { + withStyle(timestampSpanStyle(textSize.value, timestampColor)) { append(message.timestamp) } append(" ") @@ -313,14 +289,7 @@ fun ModerationMessageComposable( onClick = { offset -> annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> - try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - .launchUrl(context, annotation.item.toUri()) - } catch (e: Exception) { - Log.e("ModerationMessage", "Error launching URL", e) - } + launchCustomTab(context, annotation.item) } } ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt index 46fcd74c3..26a5c3fb4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt @@ -1,10 +1,6 @@ package com.flxrs.dankchat.chat.compose.messages -import android.util.Log -import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -27,28 +23,23 @@ 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.platform.LocalDensity +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.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 androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.chat.compose.BadgeUi import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.EmoteDimensions -import com.flxrs.dankchat.chat.compose.messages.common.BadgeInlineContent -import com.flxrs.dankchat.chat.compose.EmoteScaling -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.StackedEmote -import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.chat.compose.appendWithLinks +import com.flxrs.dankchat.chat.compose.messages.common.MessageTextWithInlineContent +import com.flxrs.dankchat.chat.compose.messages.common.launchCustomTab +import com.flxrs.dankchat.chat.compose.messages.common.timestampSpanStyle import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor import com.flxrs.dankchat.chat.compose.rememberNormalizedColor @@ -120,7 +111,6 @@ private fun WhisperMessageText( onEmoteClick: (emotes: List) -> Unit, ) { val context = LocalPlatformContext.current - val emoteCoordinator = LocalEmoteAnimationCoordinator.current val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val senderColor = rememberNormalizedColor(message.rawSenderColor, backgroundColor) val recipientColor = rememberNormalizedColor(message.rawRecipientColor, backgroundColor) @@ -131,15 +121,7 @@ private fun WhisperMessageText( buildAnnotatedString { // Timestamp if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = (fontSize * 0.95f).sp, - color = defaultTextColor, - letterSpacing = (-0.03).em - ) - ) { + withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { append(message.timestamp) append(" ") } @@ -215,70 +197,14 @@ private fun WhisperMessageText( } } - // Build inline content providers for SubcomposeLayout - val badgeSize = EmoteScaling.getBadgeSize(fontSize) - val inlineContentProviders: Map Unit> = remember(message.badges, message.emotes, fontSize) { - buildMap Unit> { - // Badge providers - message.badges.forEach { badge -> - put("BADGE_${badge.position}") { - BadgeInlineContent(badge = badge, size = badgeSize) - } - } - - // Emote providers - message.emotes.forEach { emote -> - put("EMOTE_${emote.code}") { - StackedEmote( - emote = emote, - fontSize = fontSize, - emoteCoordinator = emoteCoordinator, - animateGifs = animateGifs, - modifier = Modifier, - onClick = { onEmoteClick(emote.emotes) } - ) - } - } - } - } - - // Compute known dimensions from dimension cache to skip measurement subcomposition - val density = LocalDensity.current - val knownDimensions = remember(message.badges, message.emotes, fontSize, emoteCoordinator) { - buildMap { - val badgeSizePx = with(density) { badgeSize.toPx().toInt() } - message.badges.forEach { badge -> - put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) - } - val baseHeight = EmoteScaling.getBaseHeight(fontSize) - val baseHeightPx = with(density) { baseHeight.toPx().toInt() } - message.emotes.forEach { emote -> - val id = "EMOTE_${emote.code}" - if (emote.urls.size == 1) { - val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } else { - val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" - val dims = emoteCoordinator.dimensionCache.get(cacheKey) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } - } - } - } - } - - // Use SubcomposeLayout to measure inline content, then render text - TextWithMeasuredInlineContent( - text = annotatedString, - inlineContentProviders = inlineContentProviders, - style = TextStyle(fontSize = fontSize.sp), - knownDimensions = knownDimensions, - modifier = Modifier.fillMaxWidth(), + MessageTextWithInlineContent( + annotatedString = annotatedString, + badges = message.badges, + emotes = message.emotes, + fontSize = fontSize, + animateGifs = animateGifs, + onEmoteClick = onEmoteClick, onTextClick = { offset -> - // Handle username clicks annotatedString.getStringAnnotations("USER", offset, offset) .firstOrNull()?.let { annotation -> val parts = annotation.item.split("|") @@ -290,22 +216,14 @@ private fun WhisperMessageText( } } - // Handle URL clicks annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> - try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - .launchUrl(context, annotation.item.toUri()) - } catch (e: Exception) { - Log.e("WhisperMessage", "Error launching URL", e) - } + launchCustomTab(context, annotation.item) } }, onTextLongClick = { onMessageLongClick(message.id, message.fullMessage) - } + }, ) } @@ -338,15 +256,7 @@ fun PointRedemptionMessageComposable( buildAnnotatedString { // Timestamp if (message.timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = (fontSize * 0.95f).sp, - color = timestampColor, - letterSpacing = (-0.03).em - ) - ) { + withStyle(timestampSpanStyle(fontSize, timestampColor)) { append(message.timestamp) } append(" ") diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextRenderer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextRenderer.kt new file mode 100644 index 000000000..ad42ee76a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextRenderer.kt @@ -0,0 +1,131 @@ +package com.flxrs.dankchat.chat.compose.messages.common + +import android.content.Context +import android.util.Log +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +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.em +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import com.flxrs.dankchat.chat.compose.BadgeUi +import com.flxrs.dankchat.chat.compose.EmoteDimensions +import com.flxrs.dankchat.chat.compose.EmoteScaling +import com.flxrs.dankchat.chat.compose.EmoteUi +import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.chat.compose.StackedEmote +import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun MessageTextWithInlineContent( + annotatedString: AnnotatedString, + badges: ImmutableList, + emotes: ImmutableList, + fontSize: Float, + animateGifs: Boolean, + onTextClick: (Int) -> Unit, + onEmoteClick: (List) -> Unit, + modifier: Modifier = Modifier, + onTextLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val emoteCoordinator = LocalEmoteAnimationCoordinator.current + val density = LocalDensity.current + + val badgeSize = EmoteScaling.getBadgeSize(fontSize) + val inlineContentProviders: Map 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) } + ) + } + } + } + } + + 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 = EmoteScaling.getBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + emotes.forEach { emote -> + val id = "EMOTE_${emote.code}" + when { + emote.urls.size == 1 -> { + val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } + + else -> { + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + val dims = emoteCoordinator.dimensionCache.get(cacheKey) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } + } + } + } + } + + 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) { + Log.e("MessageUrl", "Error launching URL", e) + } +} + +fun timestampSpanStyle(fontSize: Float, color: Color) = SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + color = color, + letterSpacing = (-0.03).em, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt index 79d3385fe..f2ab849ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt @@ -1,7 +1,5 @@ package com.flxrs.dankchat.chat.compose.messages.common -import android.util.Log -import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -18,13 +16,9 @@ import androidx.compose.ui.platform.LocalContext 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.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.core.net.toUri import com.flxrs.dankchat.chat.compose.appendWithLinks import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor import com.flxrs.dankchat.chat.compose.rememberBackgroundColor @@ -53,15 +47,7 @@ fun SimpleMessageContainer( val annotatedString = remember(message, timestamp, textColor, linkColor, timestampColor, fontSize) { buildAnnotatedString { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = fontSize * 0.95f, - color = timestampColor, - letterSpacing = (-0.03).em, - ) - ) { + withStyle(timestampSpanStyle(fontSize.value, timestampColor)) { append(timestamp) } append(" ") @@ -86,14 +72,7 @@ fun SimpleMessageContainer( onClick = { offset -> annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> - try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - .launchUrl(context, annotation.item.toUri()) - } catch (e: Exception) { - Log.e("SimpleMessageContainer", "Error launching URL", e) - } + launchCustomTab(context, annotation.item) } } ) 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 index 5eb80e9cd..25009cffc 100644 --- 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 @@ -2,6 +2,8 @@ package com.flxrs.dankchat.data.repo.chat import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -11,16 +13,16 @@ import org.koin.core.annotation.Single class ChatChannelProvider(preferenceStore: DankChatPreferenceStore) { private val _activeChannel = MutableStateFlow(null) - private val _channels = MutableStateFlow?>(preferenceStore.channels.takeIf { it.isNotEmpty() }) + private val _channels = MutableStateFlow?>(preferenceStore.channels.takeIf { it.isNotEmpty() }?.toImmutableList()) val activeChannel: StateFlow = _activeChannel.asStateFlow() - val channels: StateFlow?> = _channels.asStateFlow() + val channels: StateFlow?> = _channels.asStateFlow() fun setActiveChannel(channel: UserName?) { _activeChannel.value = channel } fun setChannels(channels: List) { - _channels.value = channels + _channels.value = channels.toImmutableList() } } 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 index 4c27ceff4..df9276db1 100644 --- 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 @@ -11,6 +11,9 @@ 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 @@ -34,8 +37,8 @@ class ChatNotificationRepository( private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - private val _mentions = MutableStateFlow>(emptyList()) - private val _whispers = MutableStateFlow>(emptyList()) + private val _mentions = MutableStateFlow>(persistentListOf()) + private val _whispers = MutableStateFlow>(persistentListOf()) private val _notificationsFlow = MutableSharedFlow>(replay = 0, extraBufferCapacity = 10) private val _channelMentionCount = mutableSharedFlowOf(mutableMapOf()) private val _unreadMessagesMap = mutableSharedFlowOf(mutableMapOf()) @@ -49,13 +52,13 @@ class ChatNotificationRepository( val unreadMessagesMap: SharedFlow> = _unreadMessagesMap.asSharedFlow() val hasMentions = channelMentionCount.map { it.any { (key, value) -> key != WhisperMessage.WHISPER_CHANNEL && value > 0 } } val hasWhispers = channelMentionCount.map { it.getOrDefault(WhisperMessage.WHISPER_CHANNEL, 0) > 0 } - val mentions: StateFlow> = _mentions - val whispers: StateFlow> = _whispers + val mentions: StateFlow> = _mentions + val whispers: StateFlow> = _whispers fun addMentions(items: List) { if (items.isEmpty()) return _mentions.update { current -> - current.addAndLimit(items, scrollBackLength, messageProcessor::onMessageRemoved) + current.addAndLimit(items, scrollBackLength, messageProcessor::onMessageRemoved).toImmutableList() } } @@ -65,12 +68,13 @@ class ChatNotificationRepository( (current + items) .distinctBy { it.message.id } .sortedBy { it.message.timestamp } + .toImmutableList() } } fun addWhisper(item: ChatItem) { _whispers.update { current -> - current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved) + current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved).toImmutableList() } } 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 index 0c484fbf6..97737c9b6 100644 --- 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 @@ -9,6 +9,9 @@ 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 @@ -30,8 +33,8 @@ class StreamDataRepository( ) { private val scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) private var fetchTimerJob: Job? = null - private val _streamData = MutableStateFlow>(emptyList()) - val streamData: StateFlow> = _streamData.asStateFlow() + private val _streamData = MutableStateFlow>(persistentListOf()) + val streamData: StateFlow> = _streamData.asStateFlow() fun fetchStreamData(channels: List) { cancelStreamData() @@ -55,7 +58,7 @@ class StreamDataRepository( StreamData(channel = it.userLogin, formattedData = formatted) }.orEmpty() - _streamData.value = data + _streamData.value = data.toImmutableList() } } } @@ -63,7 +66,7 @@ class StreamDataRepository( fun cancelStreamData() { fetchTimerJob?.cancel() fetchTimerJob = null - _streamData.value = emptyList() + _streamData.value = persistentListOf() } companion object { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt index 101b79dc1..88db28823 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt @@ -85,6 +85,7 @@ 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.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R @@ -209,36 +210,10 @@ fun ChatInputLayout( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { - 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 = stringResource(R.string.reply_header, replyName?.value.orEmpty()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - IconButton( - onClick = onReplyDismiss, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.dialog_dismiss), - modifier = Modifier.size(16.dp) - ) - } - } - HorizontalDivider( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - modifier = Modifier.padding(horizontal = 16.dp) - ) - } + InputOverlayHeader( + text = stringResource(R.string.reply_header, replyName?.value.orEmpty()), + onDismiss = onReplyDismiss, + ) } // Whisper Header @@ -247,36 +222,10 @@ fun ChatInputLayout( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { - 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 = stringResource(R.string.whisper_header, whisperTarget?.value.orEmpty()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - IconButton( - onClick = onWhisperDismiss, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.dialog_dismiss), - modifier = Modifier.size(16.dp) - ) - } - } - HorizontalDivider( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - modifier = Modifier.padding(horizontal = 16.dp) - ) - } + InputOverlayHeader( + text = stringResource(R.string.whisper_header, whisperTarget?.value.orEmpty()), + onDismiss = onWhisperDismiss, + ) } // Text Field @@ -367,172 +316,41 @@ fun ChatInputLayout( } // Actions Row — uses BoxWithConstraints to hide actions that don't fit - val actionsRowContent: @Composable () -> Unit = { - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) - ) { - val iconSize = 40.dp - // Fixed slots: emote + overflow + send (+ whisper if present) - val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 - val availableForActions = maxWidth - iconSize * fixedSlots - val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) - visibleActions = effectiveActions.take(maxVisibleActions).toImmutableList() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - // Emote/Keyboard Button (start-aligned, always visible) - IconButton( - onClick = { - if (isEmoteMenuOpen) { - focusRequester.requestFocus() - } - onEmoteClick() - }, - enabled = enabled, - modifier = Modifier.size(iconSize) - ) { - Icon( - imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.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 - val endAlignedContent: @Composable () -> Unit = { - // Overflow Button (leading the end-aligned group) - if (showQuickActions) { - val overflowButton: @Composable () -> Unit = { - IconButton( - onClick = { - if (tourState.overflowMenuTooltipState != null) { - tourState.onAdvance?.invoke() - } else { - onOverflowExpandedChanged(!quickActionsExpanded) - } - }, - modifier = Modifier.size(iconSize) - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - if (tourState.overflowMenuTooltipState != null) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - TourTooltip( - text = stringResource(R.string.tour_overflow_menu), - onAction = { tourState.onAdvance?.invoke() }, - onSkip = { tourState.onSkip?.invoke() }, - ) - }, - state = tourState.overflowMenuTooltipState, - hasAction = true, - ) { - overflowButton() - } - } else { - 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 - for (action in visibleActions) { - InputActionButton( - action = action, - enabled = enabled, - hasLastMessage = hasLastMessage, - isStreamActive = isStreamActive, - isFullscreen = isFullscreen, - onSearchClick = onSearchClick, - onLastMessageClick = onLastMessageClick, - onToggleStream = onToggleStream, - onChangeRoomState = onChangeRoomState, - onToggleFullscreen = onToggleFullscreen, - onToggleInput = onToggleInput, - modifier = Modifier.size(iconSize), - ) - } - - // Send Button (Right) - SendButton( - enabled = canSend, - onSend = onSend, - ) - } - - if (tourState.inputActionsTooltipState != null) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - TourTooltip( - text = stringResource(R.string.tour_input_actions), - onAction = { tourState.onAdvance?.invoke() }, - onSkip = { tourState.onSkip?.invoke() }, - ) - }, - state = tourState.inputActionsTooltipState, - onDismissRequest = {}, - focusable = true, - hasAction = true, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - endAlignedContent() - } - } - } else { - endAlignedContent() - } - } - } - } - - actionsRowContent() + InputActionsRow( + effectiveActions = effectiveActions, + isEmoteMenuOpen = isEmoteMenuOpen, + enabled = enabled, + showQuickActions = showQuickActions, + tourState = tourState, + quickActionsExpanded = quickActionsExpanded, + canSend = canSend, + hasLastMessage = hasLastMessage, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + focusRequester = focusRequester, + onEmoteClick = onEmoteClick, + onOverflowExpandedChanged = onOverflowExpandedChanged, + onNewWhisper = onNewWhisper, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onChangeRoomState = onChangeRoomState, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + onSend = onSend, + onVisibleActionsChanged = { visibleActions = it }, + ) } } } Box(modifier = modifier.fillMaxWidth()) { - if (tourState.swipeGestureTooltipState != null) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - TourTooltip( - text = stringResource(R.string.tour_swipe_gesture), - onAction = { tourState.onAdvance?.invoke() }, - onSkip = { tourState.onSkip?.invoke() }, - ) - }, - state = tourState.swipeGestureTooltipState, - hasAction = true, - ) { - inputContent() - } - } else { + OptionalTourTooltip( + tooltipState = tourState.swipeGestureTooltipState, + text = stringResource(R.string.tour_swipe_gesture), + onAdvance = tourState.onAdvance, + onSkip = tourState.onSkip, + ) { inputContent() } @@ -820,6 +638,266 @@ private fun InputActionButton( } } +@Composable +private fun InputOverlayHeader( + text: String, + onDismiss: () -> Unit, +) { + 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) + ) + } + } + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InputActionsRow( + effectiveActions: ImmutableList, + isEmoteMenuOpen: Boolean, + enabled: Boolean, + showQuickActions: Boolean, + tourState: TourOverlayState, + quickActionsExpanded: Boolean, + canSend: Boolean, + hasLastMessage: Boolean, + isStreamActive: Boolean, + isFullscreen: Boolean, + focusRequester: FocusRequester, + onEmoteClick: () -> Unit, + onOverflowExpandedChanged: (Boolean) -> Unit, + onNewWhisper: (() -> Unit)?, + onSearchClick: () -> Unit, + onLastMessageClick: () -> Unit, + onToggleStream: () -> Unit, + onChangeRoomState: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + onSend: () -> Unit, + onVisibleActionsChanged: (ImmutableList) -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + ) { + val iconSize = 40.dp + // Fixed slots: emote + overflow + send (+ whisper if present) + val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 + val availableForActions = maxWidth - iconSize * fixedSlots + val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) + val visibleActions = effectiveActions.take(maxVisibleActions).toImmutableList() + onVisibleActionsChanged(visibleActions) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + // Emote/Keyboard Button (start-aligned, always visible) + IconButton( + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() + } + onEmoteClick() + }, + enabled = enabled, + modifier = Modifier.size(iconSize) + ) { + Icon( + imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.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 + OptionalTourTooltip( + tooltipState = tourState.inputActionsTooltipState, + text = stringResource(R.string.tour_input_actions), + onAdvance = tourState.onAdvance, + onSkip = tourState.onSkip, + focusable = true, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + EndAlignedActionGroup( + visibleActions = visibleActions, + iconSize = iconSize, + showQuickActions = showQuickActions, + tourState = tourState, + quickActionsExpanded = quickActionsExpanded, + canSend = canSend, + enabled = enabled, + hasLastMessage = hasLastMessage, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + onOverflowExpandedChanged = onOverflowExpandedChanged, + onNewWhisper = onNewWhisper, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onChangeRoomState = onChangeRoomState, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + onSend = onSend, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EndAlignedActionGroup( + visibleActions: ImmutableList, + iconSize: Dp, + showQuickActions: Boolean, + tourState: TourOverlayState, + quickActionsExpanded: Boolean, + canSend: Boolean, + enabled: Boolean, + hasLastMessage: Boolean, + isStreamActive: Boolean, + isFullscreen: Boolean, + onOverflowExpandedChanged: (Boolean) -> Unit, + onNewWhisper: (() -> Unit)?, + onSearchClick: () -> Unit, + onLastMessageClick: () -> Unit, + onToggleStream: () -> Unit, + onChangeRoomState: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + onSend: () -> Unit, +) { + // Overflow Button (leading the end-aligned group) + if (showQuickActions) { + val overflowButton: @Composable () -> Unit = { + IconButton( + onClick = { + if (tourState.overflowMenuTooltipState != null) { + tourState.onAdvance?.invoke() + } else { + onOverflowExpandedChanged(!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 + for (action in visibleActions) { + InputActionButton( + action = action, + enabled = enabled, + hasLastMessage = hasLastMessage, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onChangeRoomState = onChangeRoomState, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + modifier = Modifier.size(iconSize), + ) + } + + // Send Button (Right) + SendButton( + enabled = canSend, + onSend = onSend, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private 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( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt index 11328316d..5ab6d2e06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt @@ -1,6 +1,6 @@ package com.flxrs.dankchat.main.compose -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams import com.flxrs.dankchat.chat.user.UserPopupStateParams @@ -145,7 +145,7 @@ class DialogStateViewModel( } } -@Stable +@Immutable data class DialogState( val showAddChannel: Boolean = false, val showManageChannels: Boolean = false, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt index 8c3d49e06..04806646b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt @@ -65,8 +65,6 @@ fun MainScreenDialogs( val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() val channelRepository: ChannelRepository = koinInject() - // region Channel dialogs - if (dialogState.showAddChannel) { AddChannelDialog( onDismiss = dialogViewModel::dismissAddChannel, @@ -130,10 +128,6 @@ fun MainScreenDialogs( ) } - // endregion - - // region Auth dialogs - if (dialogState.showLogout) { ConfirmationDialog( title = stringResource(R.string.confirm_logout_question), @@ -188,10 +182,6 @@ fun MainScreenDialogs( ) } - // endregion - - // region Message interactions - dialogState.messageOptionsParams?.let { params -> val viewModel: MessageOptionsComposeViewModel = koinViewModel( key = params.messageId, @@ -323,6 +313,4 @@ fun MainScreenDialogs( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) } - - // endregion } From 9f4effdaedbba2d96fb3397d7c836e6b5c2f50e8 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Mar 2026 22:42:45 +0100 Subject: [PATCH 105/349] fix: Only post Connected system message to channels that actually transitioned --- .../dankchat/data/repo/chat/ChatEventProcessor.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 1c7345717..4a0b94052 100644 --- 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 @@ -283,10 +283,14 @@ class ChatEventProcessor( isAnonymous -> ConnectionState.CONNECTED_NOT_LOGGED_IN else -> ConnectionState.CONNECTED } - val previousState = chatConnector.getConnectionState(channel).value + val transitioning = chatChannelProvider.channels.value.orEmpty() + .filter { chatConnector.getConnectionState(it).value != state } + .toSet() + chatConnector.setAllConnectionStates(state) - if (previousState != state) { - postSystemMessageAndReconnect(state.toSystemMessageType()) + + if (transitioning.isNotEmpty()) { + postSystemMessageAndReconnect(state.toSystemMessageType(), transitioning) } } From 7ff7102125dc0d549a4a1ba7d6b6acf82d84f71a Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 00:01:58 +0100 Subject: [PATCH 106/349] =?UTF-8?q?refactor:=20Restructure=20packages=20?= =?UTF-8?q?=E2=80=94=20add=20ui/=20layer,=20flatten=20compose/,=20move=20a?= =?UTF-8?q?uth=20to=20data/,=20delete=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 4 +- .../com/flxrs/dankchat/DankChatViewModel.kt | 4 +- .../dankchat/chat/mention/MentionViewModel.kt | 26 -------- .../dankchat/data/api/auth/AuthApiClient.kt | 2 +- .../data/api/eventapi/EventSubManager.kt | 2 +- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 2 +- .../dankchat/{ => data}/auth/AuthDataStore.kt | 2 +- .../dankchat/{ => data}/auth/AuthSettings.kt | 2 +- .../{ => data}/auth/AuthStateCoordinator.kt | 2 +- .../{ => data}/chat/ChatImportance.kt | 2 +- .../dankchat/{ => data}/chat/ChatItem.kt | 2 +- .../data/notification/NotificationService.kt | 2 +- .../dankchat/data/repo/RepliesRepository.kt | 4 +- .../data/repo/channel/ChannelRepository.kt | 2 +- .../data/repo/chat/ChatEventProcessor.kt | 11 ++-- .../data/repo/chat/ChatLoadingStep.kt | 7 +- .../data/repo/chat/ChatMessageRepository.kt | 3 +- .../repo/chat/ChatNotificationRepository.kt | 2 +- .../dankchat/data/repo/chat/ChatRepository.kt | 4 +- .../data/repo/chat/MessageProcessor.kt | 2 +- .../data/repo/chat/RecentMessagesHandler.kt | 6 +- .../data/repo/command/CommandRepository.kt | 2 +- .../data/repo/data/DataLoadingStep.kt | 60 +++++++++++++---- .../dankchat/data/repo/data/DataRepository.kt | 2 +- .../data/repo/emote/EmoteRepository.kt | 5 +- .../{main => data/repo/stream}/StreamData.kt | 2 +- .../data/repo/stream/StreamDataRepository.kt | 3 +- .../data/twitch/chat/ChatConnection.kt | 2 +- .../twitch/command/TwitchCommandRepository.kt | 2 +- .../data/twitch/message/AutomodMessage.kt | 2 +- .../data/twitch/message/ModerationMessage.kt | 64 +++++++++---------- .../data/twitch/message/SystemMessageType.kt | 4 +- .../data/twitch/pubsub/PubSubManager.kt | 2 +- .../com/flxrs/dankchat/di/ConnectionModule.kt | 2 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 2 +- .../dankchat/domain/ChannelDataCoordinator.kt | 2 +- .../flxrs/dankchat/main/StreamWebViewModel.kt | 59 ----------------- .../preferences/DankChatPreferenceStore.kt | 6 +- .../components/PreferenceCategory.kt | 2 +- .../preferences/components/PreferenceItem.kt | 2 +- .../developer/DeveloperSettingsViewModel.kt | 2 +- .../customlogin/CustomLoginViewModel.kt | 2 +- .../notifications/ignores/IgnoresScreen.kt | 2 +- .../overview/OverviewSettingsScreen.kt | 2 +- .../{ => ui}/changelog/ChangelogScreen.kt | 2 +- .../changelog/ChangelogSheetViewModel.kt | 2 +- .../{ => ui}/changelog/ChangelogState.kt | 2 +- .../{ => ui}/changelog/DankChatChangelog.kt | 2 +- .../{ => ui}/changelog/DankChatVersion.kt | 2 +- .../compose => ui/chat}/AdaptiveTextColor.kt | 4 +- .../compose => ui/chat}/BackgroundColor.kt | 2 +- .../compose => ui/chat}/ChatComposable.kt | 8 +-- .../compose => ui/chat}/ChatMessageMapper.kt | 9 +-- .../compose => ui/chat}/ChatMessageText.kt | 2 +- .../compose => ui/chat}/ChatMessageUiState.kt | 3 +- .../{chat/compose => ui/chat}/ChatScreen.kt | 36 +++++------ .../compose => ui/chat}/ChatScrollBehavior.kt | 2 +- .../chat/ChatViewModel.kt} | 10 +-- .../chat}/EmoteAnimationCoordinator.kt | 2 +- .../chat}/EmoteDrawablePainter.kt | 2 +- .../{chat/compose => ui/chat}/EmoteScaling.kt | 2 +- .../compose => ui/chat}/Linkification.kt | 2 +- .../{ => ui}/chat/MessageClickEvent.kt | 0 .../{chat/compose => ui/chat}/StackedEmote.kt | 2 +- .../chat}/TextWithMeasuredInlineContent.kt | 2 +- .../chat/emote/EmoteInfoViewModel.kt} | 5 +- .../{ => ui}/chat/emote/EmoteSheetItem.kt | 2 +- .../{ => ui}/chat/emotemenu/EmoteItem.kt | 2 +- .../{ => ui}/chat/emotemenu/EmoteMenuTab.kt | 2 +- .../chat/emotemenu/EmoteMenuTabItem.kt | 2 +- .../chat/history/MessageHistoryViewModel.kt} | 20 +++--- .../chat/mention}/MentionComposable.kt | 14 ++-- .../chat/mention/MentionViewModel.kt} | 12 ++-- .../chat/message}/MessageOptionsParams.kt | 2 +- .../chat/message/MessageOptionsViewModel.kt} | 4 +- .../chat}/messages/AutomodMessage.kt | 19 +++--- .../chat}/messages/PrivMessage.kt | 22 +++---- .../chat}/messages/SystemMessages.kt | 20 +++--- .../chat}/messages/WhisperAndRedemption.kt | 20 +++--- .../chat}/messages/common/InlineContent.kt | 14 ++-- .../messages/common/MessageTextBuilders.kt | 10 +-- .../messages/common/MessageTextRenderer.kt | 16 ++--- .../messages/common/SimpleMessageContainer.kt | 8 +-- .../chat/replies}/RepliesComposable.kt | 15 ++--- .../{ => ui}/chat/replies/RepliesState.kt | 6 +- .../chat/replies/RepliesViewModel.kt} | 10 ++- .../{ => ui}/chat/search/ChatItemFilter.kt | 4 +- .../{ => ui}/chat/search/ChatSearchFilter.kt | 2 +- .../chat/search/ChatSearchFilterParser.kt | 2 +- .../chat/search/SearchFilterSuggestions.kt | 4 +- .../{ => ui}/chat/suggestion/Suggestion.kt | 2 +- .../chat/suggestion/SuggestionProvider.kt | 2 +- .../chat/user}/UserPopupDialog.kt | 5 +- .../{ => ui}/chat/user/UserPopupState.kt | 2 +- .../chat/user/UserPopupStateParams.kt | 2 +- .../chat/user/UserPopupViewModel.kt} | 4 +- .../compose => ui/login}/LoginScreen.kt | 3 +- .../dankchat/{ => ui}/login/LoginViewModel.kt | 4 +- .../compose => ui/main}/DraggableHandle.kt | 2 +- .../compose => ui/main}/EmptyStateContent.kt | 2 +- .../compose => ui/main}/FloatingToolbar.kt | 17 ++--- .../dankchat/{ => ui}/main/InputState.kt | 2 +- .../dankchat/{ => ui}/main/MainActivity.kt | 18 ++---- .../{main/compose => ui/main}/MainAppBar.kt | 32 +++++----- .../dankchat/{ => ui}/main/MainDestination.kt | 2 +- .../flxrs/dankchat/{ => ui}/main/MainEvent.kt | 2 +- .../{main/compose => ui/main}/MainEventBus.kt | 3 +- .../{main/compose => ui/main}/MainScreen.kt | 43 +++++++++---- .../main}/MainScreenEventHandler.kt | 11 ++-- .../main}/MainScreenViewModel.kt | 2 +- .../compose => ui/main}/QuickActionsMenu.kt | 3 +- .../{ => ui}/main/RepeatedSendData.kt | 2 +- .../channel}/ChannelManagementViewModel.kt | 2 +- .../main/channel}/ChannelPagerViewModel.kt | 2 +- .../compose => ui/main/channel}/ChannelTab.kt | 2 +- .../main/channel}/ChannelTabRow.kt | 2 +- .../main/channel}/ChannelTabViewModel.kt | 2 +- .../main/dialog}/AddChannelDialog.kt | 2 +- .../main/dialog}/ConfirmationDialog.kt | 2 +- .../main/dialog}/DialogStateViewModel.kt | 6 +- .../main/dialog}/EditChannelDialog.kt | 2 +- .../main/dialog}/EmoteInfoDialog.kt | 4 +- .../main/dialog}/MainScreenDialogs.kt | 35 +++++----- .../main/dialog}/ManageChannelsDialog.kt | 2 +- .../main/dialog}/MessageOptionsDialog.kt | 2 +- .../main/dialog}/RoomStateDialog.kt | 17 ++--- .../main/input}/ChatBottomBar.kt | 3 +- .../main/input}/ChatInputLayout.kt | 6 +- .../main/input}/ChatInputViewModel.kt | 16 +++-- .../main/input}/SuggestionDropdown.kt | 4 +- .../sheets => ui/main/sheet}/EmoteMenu.kt | 7 +- .../main/sheet}/EmoteMenuSheet.kt | 7 +- .../main/sheet}/EmoteMenuViewModel.kt | 6 +- .../main/sheet}/FullScreenSheetOverlay.kt | 19 +++--- .../sheets => ui/main/sheet}/MentionSheet.kt | 14 ++-- .../main/sheet}/MessageHistorySheet.kt | 20 +++--- .../main/sheet}/MoreActionsSheet.kt | 2 +- .../sheets => ui/main/sheet}/RepliesSheet.kt | 14 ++-- .../main/sheet}/SheetNavigationViewModel.kt | 5 +- .../compose => ui/main/stream}/StreamView.kt | 2 +- .../main/stream}/StreamViewModel.kt | 4 +- .../{ => ui}/main/stream/StreamWebView.kt | 2 +- .../onboarding/OnboardingDataStore.kt | 2 +- .../{ => ui}/onboarding/OnboardingScreen.kt | 2 +- .../{ => ui}/onboarding/OnboardingSettings.kt | 2 +- .../onboarding/OnboardingViewModel.kt | 4 +- .../{ => ui}/share/ShareUploadActivity.kt | 4 +- .../dankchat/{ => ui}/theme/DankChatTheme.kt | 2 +- .../{ => ui}/tour/FeatureTourViewModel.kt | 6 +- .../com/flxrs/dankchat/utils/DateTimeUtils.kt | 3 +- .../{chat/compose => utils}/TextResource.kt | 2 +- .../utils/extensions/ChatListOperations.kt | 2 +- .../dankchat/utils/extensions/Extensions.kt | 2 +- .../utils/extensions/ModerationOperations.kt | 4 +- .../extensions/SystemMessageOperations.kt | 2 +- .../flxrs/dankchat/data/irc/IrcMessageTest.kt | 29 +++++---- .../SuggestionProviderExtractWordTest.kt | 2 +- .../main}/SuggestionReplacementTest.kt | 4 +- .../{ => ui}/tour/FeatureTourViewModelTest.kt | 6 +- .../flxrs/dankchat/utils/DateTimeUtilsTest.kt | 3 +- 160 files changed, 537 insertions(+), 583 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt rename app/src/main/kotlin/com/flxrs/dankchat/{ => data}/auth/AuthDataStore.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => data}/auth/AuthSettings.kt (91%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => data}/auth/AuthStateCoordinator.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => data}/chat/ChatImportance.kt (64%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => data}/chat/ChatItem.kt (90%) rename app/src/main/kotlin/com/flxrs/dankchat/{main => data/repo/stream}/StreamData.kt (71%) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/main/StreamWebViewModel.kt rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/changelog/ChangelogScreen.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/changelog/ChangelogSheetViewModel.kt (92%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/changelog/ChangelogState.kt (63%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/changelog/DankChatChangelog.kt (71%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/changelog/DankChatVersion.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/AdaptiveTextColor.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/BackgroundColor.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/ChatComposable.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/ChatMessageMapper.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/ChatMessageText.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/ChatMessageUiState.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/ChatScreen.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/ChatScrollBehavior.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose/ChatComposeViewModel.kt => ui/chat/ChatViewModel.kt} (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/EmoteAnimationCoordinator.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/EmoteDrawablePainter.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/EmoteScaling.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/Linkification.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/MessageClickEvent.kt (100%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/StackedEmote.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/TextWithMeasuredInlineContent.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/emote/compose/EmoteInfoComposeViewModel.kt => ui/chat/emote/EmoteInfoViewModel.kt} (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/emote/EmoteSheetItem.kt (89%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/emotemenu/EmoteItem.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/emotemenu/EmoteMenuTab.kt (62%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/emotemenu/EmoteMenuTabItem.kt (75%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/history/compose/MessageHistoryComposeViewModel.kt => ui/chat/history/MessageHistoryViewModel.kt} (90%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/mention/compose => ui/chat/mention}/MentionComposable.kt (86%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/mention/compose/MentionComposeViewModel.kt => ui/chat/mention/MentionViewModel.kt} (92%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/message/compose => ui/chat/message}/MessageOptionsParams.kt (85%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/message/compose/MessageOptionsComposeViewModel.kt => ui/chat/message/MessageOptionsViewModel.kt} (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/AutomodMessage.kt (93%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/PrivMessage.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/SystemMessages.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/WhisperAndRedemption.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/common/InlineContent.kt (83%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/common/MessageTextBuilders.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/common/MessageTextRenderer.kt (91%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => ui/chat}/messages/common/SimpleMessageContainer.kt (91%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/replies/compose => ui/chat/replies}/RepliesComposable.kt (85%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/replies/RepliesState.kt (71%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/replies/compose/RepliesComposeViewModel.kt => ui/chat/replies/RepliesViewModel.kt} (90%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/search/ChatItemFilter.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/search/ChatSearchFilter.kt (93%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/search/ChatSearchFilterParser.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/search/SearchFilterSuggestions.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/suggestion/Suggestion.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/suggestion/SuggestionProvider.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/user/compose => ui/chat/user}/UserPopupDialog.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/user/UserPopupState.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/chat/user/UserPopupStateParams.kt (92%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/user/UserPopupComposeViewModel.kt => ui/chat/user/UserPopupViewModel.kt} (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{login/compose => ui/login}/LoginScreen.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/login/LoginViewModel.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/DraggableHandle.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/EmptyStateContent.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/FloatingToolbar.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/main/InputState.kt (87%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/main/MainActivity.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/MainAppBar.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/main/MainDestination.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/main/MainEvent.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/MainEventBus.kt (89%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/MainScreen.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/MainScreenEventHandler.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/MainScreenViewModel.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/QuickActionsMenu.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/main/RepeatedSendData.kt (66%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/channel}/ChannelManagementViewModel.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/channel}/ChannelPagerViewModel.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/channel}/ChannelTab.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/channel}/ChannelTabRow.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/channel}/ChannelTabViewModel.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/dialog}/AddChannelDialog.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/dialog}/ConfirmationDialog.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/dialog}/DialogStateViewModel.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/dialog}/EditChannelDialog.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/dialog}/EmoteInfoDialog.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/dialog}/MainScreenDialogs.kt (91%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/dialog}/ManageChannelsDialog.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/dialog}/MessageOptionsDialog.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/dialog}/RoomStateDialog.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/input}/ChatBottomBar.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/input}/ChatInputLayout.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/input}/ChatInputViewModel.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/input}/SuggestionDropdown.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/sheets => ui/main/sheet}/EmoteMenu.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/sheets => ui/main/sheet}/EmoteMenuSheet.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/sheet}/EmoteMenuViewModel.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/sheet}/FullScreenSheetOverlay.kt (91%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/sheets => ui/main/sheet}/MentionSheet.kt (96%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/sheets => ui/main/sheet}/MessageHistorySheet.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/dialogs => ui/main/sheet}/MoreActionsSheet.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose/sheets => ui/main/sheet}/RepliesSheet.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/sheet}/SheetNavigationViewModel.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/stream}/StreamView.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{main/compose => ui/main/stream}/StreamViewModel.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/main/stream/StreamWebView.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/onboarding/OnboardingDataStore.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/onboarding/OnboardingScreen.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/onboarding/OnboardingSettings.kt (89%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/onboarding/OnboardingViewModel.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/share/ShareUploadActivity.kt (99%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/theme/DankChatTheme.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{ => ui}/tour/FeatureTourViewModel.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/{chat/compose => utils}/TextResource.kt (97%) rename app/src/test/kotlin/com/flxrs/dankchat/{ => ui}/chat/suggestion/SuggestionProviderExtractWordTest.kt (98%) rename app/src/test/kotlin/com/flxrs/dankchat/{main/compose => ui/main}/SuggestionReplacementTest.kt (96%) rename app/src/test/kotlin/com/flxrs/dankchat/{ => ui}/tour/FeatureTourViewModelTest.kt (98%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf81f8806..eb2c228f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ android:foregroundServiceType="dataSync" /> diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 3f475d13c..3d706a714 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -2,8 +2,8 @@ package com.flxrs.dankchat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.auth.AuthStateCoordinator +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.chat.ChatConnector import com.flxrs.dankchat.data.repo.data.DataRepository 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 77d737203..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.ChatNotificationRepository -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(chatNotificationRepository: ChatNotificationRepository) : ViewModel() { - - val mentions: StateFlow> = chatNotificationRepository.mentions - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - val whispers: StateFlow> = chatNotificationRepository.whispers - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - - val hasMentions: StateFlow = chatNotificationRepository.hasMentions - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - val hasWhispers: StateFlow = chatNotificationRepository.hasWhispers - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) -} 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 2d15c923f..c8f54b8ea 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 @@ -1,9 +1,9 @@ package com.flxrs.dankchat.data.api.auth -import com.flxrs.dankchat.auth.AuthSettings 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.data.auth.AuthSettings import com.flxrs.dankchat.utils.extensions.decodeOrNull import io.ktor.client.call.body import io.ktor.client.statement.bodyAsText 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 1a9e8719a..fb0e0c0cf 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,7 +1,7 @@ package com.flxrs.dankchat.data.api.eventapi -import com.flxrs.dankchat.auth.AuthDataStore 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.UserStateRepository import com.flxrs.dankchat.di.DispatchersProvider 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 9d5764ecc..ac4be93ed 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 @@ -1,6 +1,5 @@ package com.flxrs.dankchat.data.api.helix -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionRequestDto @@ -12,6 +11,7 @@ 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.ShieldModeRequestDto import com.flxrs.dankchat.data.api.helix.dto.WhisperRequestDto +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix import io.ktor.client.HttpClient import io.ktor.client.request.bearerAuth diff --git a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt rename to app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt index 1b0c86b1b..1d353a5ce 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.auth +package com.flxrs.dankchat.data.auth import android.content.Context import android.content.SharedPreferences diff --git a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthSettings.kt similarity index 91% rename from app/src/main/kotlin/com/flxrs/dankchat/auth/AuthSettings.kt rename to app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthSettings.kt index 28e2794b5..b6000bc03 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthSettings.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.auth +package com.flxrs.dankchat.data.auth import kotlinx.serialization.Serializable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt rename to app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt index 64e3f032f..ac80ca573 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.auth +package com.flxrs.dankchat.data.auth import android.util.Log import android.webkit.CookieManager 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 64% 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..734e497c3 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,4 +1,4 @@ -package com.flxrs.dankchat.chat +package com.flxrs.dankchat.data.chat enum class ChatImportance { REGULAR, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt similarity index 90% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/ChatItem.kt rename to app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt index 9742ca929..98124efe4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat +package com.flxrs.dankchat.data.chat import com.flxrs.dankchat.data.twitch.message.Message 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 8de2124ee..5f39deeb7 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 @@ -23,12 +23,12 @@ 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.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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job 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 069caf007..3e5069cff 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,8 +1,8 @@ package com.flxrs.dankchat.data.repo -import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserName +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.HighlightType 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 aa518868e..092faff71 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 @@ -1,9 +1,9 @@ package com.flxrs.dankchat.data.repo.channel -import com.flxrs.dankchat.auth.AuthDataStore 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 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 index 4a0b94052..fe20c6530 100644 --- 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 @@ -1,13 +1,7 @@ package com.flxrs.dankchat.data.repo.chat -import android.graphics.Color import android.util.Log import com.flxrs.dankchat.R -import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.chat.ChatImportance -import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.compose.TextResource -import com.flxrs.dankchat.chat.toMentionTabItems import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.AutomodHeld import com.flxrs.dankchat.data.api.eventapi.AutomodUpdate @@ -16,6 +10,10 @@ import com.flxrs.dankchat.data.api.eventapi.SystemMessage 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 @@ -38,6 +36,7 @@ import com.flxrs.dankchat.data.twitch.message.hasMention 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 kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope 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 c251f79ce..d5bbccc4a 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 @@ -6,9 +6,12 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName sealed interface ChatLoadingStep { - @get:StringRes val displayNameRes: Int + @get:StringRes + val displayNameRes: Int - data class RecentMessages(val channel: UserName) : ChatLoadingStep { override val displayNameRes = R.string.data_loading_step_recent_messages } + data class RecentMessages(val channel: UserName) : ChatLoadingStep { + override val displayNameRes = R.string.data_loading_step_recent_messages + } } fun List.toDisplayStrings(resources: Resources): List { 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 index a308baf19..933d677c0 100644 --- 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 @@ -1,12 +1,11 @@ package com.flxrs.dankchat.data.repo.chat -import com.flxrs.dankchat.chat.ChatItem 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.data.twitch.message.WhisperMessage import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.extensions.addAndLimit 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 index df9276db1..6c4c6078d 100644 --- 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 @@ -1,7 +1,7 @@ package com.flxrs.dankchat.data.repo.chat -import com.flxrs.dankchat.chat.ChatItem 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 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 6d18e2129..05a9f7515 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,9 +1,9 @@ package com.flxrs.dankchat.data.repo.chat import android.graphics.Color -import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserName +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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt index 8639b7fa9..357b79bb9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt @@ -1,8 +1,8 @@ package com.flxrs.dankchat.data.repo.chat -import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.chat.ChatItem import com.flxrs.dankchat.data.irc.IrcMessage import com.flxrs.dankchat.data.repo.HighlightsRepository import com.flxrs.dankchat.data.repo.IgnoresRepository 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 index f59bbca98..eeb27b9a1 100644 --- 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 @@ -1,15 +1,15 @@ package com.flxrs.dankchat.data.repo.chat 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.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 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 23af28015..fa72f25a4 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,10 +1,10 @@ package com.flxrs.dankchat.data.repo.command import android.util.Log -import com.flxrs.dankchat.auth.AuthDataStore 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 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 2778e1cee..0c1f82a1f 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 @@ -9,20 +9,52 @@ 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 { 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 TwitchEmotes : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_twitch_emotes } - - data class ChannelBadges(val channel: UserName, val channelId: UserId) : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_channel_badges } - data class ChannelFFZEmotes(val channel: UserName, val channelId: UserId) : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_ffz_emotes } - 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 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 } + @get:StringRes + val displayNameRes: Int + + 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 TwitchEmotes : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_twitch_emotes + } + + data class ChannelBadges(val channel: UserName, val channelId: UserId) : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_channel_badges + } + + data class ChannelFFZEmotes(val channel: UserName, val channelId: UserId) : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_ffz_emotes + } + + 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 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.toDisplayStrings(resources: Resources): List { 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 a3b83ba36..105d830ba 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,7 +1,6 @@ package com.flxrs.dankchat.data.repo.data import android.util.Log -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName @@ -17,6 +16,7 @@ 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 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 4b3f40b98..9a49e687b 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 @@ -4,9 +4,9 @@ import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.util.Log -import androidx.core.graphics.toColorInt 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 @@ -20,7 +20,6 @@ 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 @@ -31,7 +30,6 @@ 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.utils.extensions.codePointAsString import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.twitch.badge.Badge import com.flxrs.dankchat.data.twitch.badge.BadgeSet @@ -52,6 +50,7 @@ import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/StreamData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamData.kt similarity index 71% rename from app/src/main/kotlin/com/flxrs/dankchat/main/StreamData.kt rename to app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamData.kt index dc85ce129..260a00f0e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/StreamData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamData.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main +package com.flxrs.dankchat.data.repo.stream import com.flxrs.dankchat.data.UserName 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 index 97737c9b6..178d9d5a3 100644 --- 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 @@ -1,10 +1,9 @@ package com.flxrs.dankchat.data.repo.stream -import com.flxrs.dankchat.auth.AuthDataStore 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.main.StreamData import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils 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 28e3204f3..1056f2eee 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 @@ -2,8 +2,8 @@ package com.flxrs.dankchat.data.twitch.chat import android.util.Log import com.flxrs.dankchat.BuildConfig -import com.flxrs.dankchat.auth.AuthDataStore 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 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 fa622f123..8c85603db 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,7 +1,6 @@ package com.flxrs.dankchat.data.twitch.command import android.util.Log -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.api.helix.HelixApiClient @@ -16,6 +15,7 @@ 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.chat.UserState import com.flxrs.dankchat.data.repo.command.CommandResult import com.flxrs.dankchat.data.toUserName 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 index b70cde964..ed62b5e29 100644 --- 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 @@ -1,9 +1,9 @@ package com.flxrs.dankchat.data.twitch.message -import com.flxrs.dankchat.chat.compose.TextResource 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, 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 fed106343..ab914a45e 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,7 +1,6 @@ package com.flxrs.dankchat.data.twitch.message import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.TextResource import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelModerateAction @@ -12,6 +11,7 @@ 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 com.flxrs.dankchat.utils.TextResource import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant @@ -125,7 +125,7 @@ data class ModerationMessage( val source = sourceBroadcasterDisplay.toString() val message = when (action) { - Action.Timeout -> when (targetUser) { + Action.Timeout -> when (targetUser) { currentUser -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_timeout_self_irc, persistentListOf(dur)) else -> when { @@ -143,9 +143,9 @@ data class ModerationMessage( } } - Action.Untimeout -> TextResource.Res(R.string.mod_untimeout, persistentListOf(creator, target)) + Action.Untimeout -> TextResource.Res(R.string.mod_untimeout, persistentListOf(creator, target)) - Action.Ban -> when (targetUser) { + Action.Ban -> when (targetUser) { currentUser -> when (creatorUserDisplay) { null -> TextResource.Res(R.string.mod_ban_self_irc) else -> when { @@ -163,11 +163,11 @@ data class ModerationMessage( } } - 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.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 -> { + Action.Delete -> { val msg = trimmedMessage(showDeletedMessage) when (creatorUserDisplay) { null -> when (msg) { @@ -182,59 +182,58 @@ data class ModerationMessage( } } - Action.Clear -> when (creatorUserDisplay) { + 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.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 { + 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)) + 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)) - Action.Followers -> when (val mins = durationInt?.takeIf { it > 0 }) { + Action.Followers -> when (val mins = durationInt?.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)) + 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)) - Action.Slow -> when (val secs = durationInt) { + Action.Slow -> when (val secs = durationInt) { 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)) + 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)) - Action.SharedTimeout -> when { + Action.SharedTimeout -> 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.SharedUntimeout -> TextResource.Res(R.string.mod_shared_untimeout, persistentListOf(creator, target, source)) - Action.SharedBan -> when { + 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)) + Action.SharedUnban -> TextResource.Res(R.string.mod_shared_unban, persistentListOf(creator, target, source)) - Action.SharedDelete -> { - val msg = trimmedMessage(showDeletedMessage) - when (msg) { + 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)) } @@ -246,8 +245,7 @@ data class ModerationMessage( Action.RemovePermittedTerm -> TextResource.Res(R.string.automod_moderation_removed_permitted_term, persistentListOf(creator, quotedTermsOrBlank)) } - val count = countSuffix() - return when (count) { + return when (val count = countSuffix()) { is TextResource.Plain -> message else -> TextResource.Res(R.string.mod_message_with_count, persistentListOf(message, count)) } 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 793524b5d..6eda86b7d 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,9 +1,9 @@ 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 sealed interface SystemMessageType { data object Connected : SystemMessageType 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 82ed72376..0a25d6657 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,7 +1,7 @@ package com.flxrs.dankchat.data.twitch.pubsub -import com.flxrs.dankchat.auth.AuthDataStore 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.di.DispatchersProvider import com.flxrs.dankchat.di.WebSocketOkHttpClient 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 b5381800d..a80310810 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt @@ -1,6 +1,6 @@ package com.flxrs.dankchat.di -import com.flxrs.dankchat.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.twitch.chat.ChatConnection import com.flxrs.dankchat.data.twitch.chat.ChatConnectionType import okhttp3.OkHttpClient 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 9a0253234..7873cfeb9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.di import android.util.Log import com.flxrs.dankchat.BuildConfig -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.api.auth.AuthApi import com.flxrs.dankchat.data.api.badges.BadgesApi import com.flxrs.dankchat.data.api.bttv.BTTVApi @@ -12,6 +11,7 @@ import com.flxrs.dankchat.data.api.helix.HelixApi 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.data.auth.AuthDataStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index e26b0e7ed..c9eb2e3ff 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -1,7 +1,7 @@ package com.flxrs.dankchat.domain -import com.flxrs.dankchat.auth.AuthDataStore import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository import com.flxrs.dankchat.data.repo.data.DataLoadingStep 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/preferences/DankChatPreferenceStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt index cce087a17..aeb0fa43a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -7,15 +7,15 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.R -import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.auth.AuthSettings -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.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.AuthSettings 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 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..40a8d847c 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( 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..03db974f7 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 @@ -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 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 db3877cf2..e35f3e998 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,8 @@ package com.flxrs.dankchat.preferences.developer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.onboarding.OnboardingDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.ui.onboarding.OnboardingDataStore import com.flxrs.dankchat.utils.extensions.withTrailingSlash import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted 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 7c66685d2..88aea3b8b 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 @@ -1,9 +1,9 @@ package com.flxrs.dankchat.preferences.developer.customlogin -import com.flxrs.dankchat.auth.AuthDataStore 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.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 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 0a3192155..1e9ac5ab8 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.LocalResources 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt index 31a647123..1e4cd3614 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -43,7 +43,7 @@ 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt index 417a6d55d..e6f8e78f5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.changelog +package com.flxrs.dankchat.ui.changelog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets 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 92% 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..097807a48 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,4 +1,4 @@ -package com.flxrs.dankchat.changelog +package com.flxrs.dankchat.ui.changelog import androidx.lifecycle.ViewModel import com.flxrs.dankchat.preferences.DankChatPreferenceStore diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt similarity index 63% rename from app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogState.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt index 723798f4d..11975a54c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt @@ -1,3 +1,3 @@ -package com.flxrs.dankchat.changelog +package com.flxrs.dankchat.ui.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/ui/changelog/DankChatChangelog.kt similarity index 71% rename from app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatChangelog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatChangelog.kt index b58577548..9c720ccca 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatChangelog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatChangelog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.changelog +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/changelog/DankChatVersion.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatVersion.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt index bd8b5040e..dd1402c3e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatVersion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.changelog +package com.flxrs.dankchat.ui.changelog import com.flxrs.dankchat.BuildConfig diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt index 8ab542856..800f2a8e3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/AdaptiveTextColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -6,7 +6,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.toArgb -import com.flxrs.dankchat.theme.LocalAdaptiveColors +import com.flxrs.dankchat.ui.theme.LocalAdaptiveColors import com.flxrs.dankchat.utils.extensions.normalizeColor import com.google.android.material.color.MaterialColors diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt index 3783ae17c..530d66a60 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/BackgroundColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt index 3d422597d..dc65661a9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -21,7 +21,7 @@ import org.koin.core.parameter.parametersOf * Extracted from ChatFragment to enable pure Compose integration. * * This composable: - * - Creates its own ChatComposeViewModel scoped to the channel + * - Creates its own ChatViewModel scoped to the channel * - Collects messages from ViewModel * - Collects settings from data stores * - Renders ChatScreen with all event handlers @@ -49,8 +49,8 @@ fun ChatComposable( onTourAdvance: (() -> Unit)? = null, onTourSkip: (() -> Unit)? = null, ) { - // Create ChatComposeViewModel with channel-specific key for proper scoping - val viewModel: ChatComposeViewModel = koinViewModel( + // Create ChatViewModel with channel-specific key for proper scoping + val viewModel: ChatViewModel = koinViewModel( key = channel.value, parameters = { parametersOf(channel) } ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt index 08273afa2..acebbf978 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -1,17 +1,16 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.ui.graphics.Color import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.ChatImportance -import com.flxrs.dankchat.chat.ChatItem 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.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.highestPriorityHighlight import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.ModerationMessage import com.flxrs.dankchat.data.twitch.message.NoticeMessage @@ -22,12 +21,14 @@ 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.recipientAliasOrFormattedName import com.flxrs.dankchat.data.twitch.message.senderAliasOrFormattedName import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettings import com.flxrs.dankchat.preferences.chat.ChatSettings import com.flxrs.dankchat.utils.DateTimeUtils +import com.flxrs.dankchat.utils.TextResource import com.google.android.material.color.MaterialColors import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt index e27ae232b..9b5d72b47 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt index 773f4a026..3f6981998 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color @@ -9,6 +9,7 @@ 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.data.twitch.message.MessageThreadHeader +import com.flxrs.dankchat.utils.TextResource import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt index 0b443479d..48ee4bcce 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState @@ -8,30 +8,22 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.background -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons - import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface @@ -50,22 +42,24 @@ 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.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.messages.AutomodMessageComposable -import com.flxrs.dankchat.chat.compose.messages.DateSeparatorComposable -import com.flxrs.dankchat.chat.compose.messages.ModerationMessageComposable -import com.flxrs.dankchat.chat.compose.messages.NoticeMessageComposable -import com.flxrs.dankchat.chat.compose.messages.PointRedemptionMessageComposable -import com.flxrs.dankchat.chat.compose.messages.PrivMessageComposable -import com.flxrs.dankchat.chat.compose.messages.SystemMessageComposable -import com.flxrs.dankchat.chat.compose.messages.UserNoticeMessageComposable -import com.flxrs.dankchat.chat.compose.messages.WhisperMessageComposable import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.main.compose.TourTooltip +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 /** * Main composable for rendering chat messages in a scrollable list. @@ -118,7 +112,7 @@ fun ChatScreen( val isAtBottom by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 && - listState.firstVisibleItemScrollOffset == 0 + listState.firstVisibleItemScrollOffset == 0 } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt index 2deeb5050..f2fa491fe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatScrollBehavior.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt index 5c2da9305..85dbc1311 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/ChatComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -1,14 +1,14 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import android.util.Log import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.auth.AuthDataStore -import com.flxrs.dankchat.chat.ChatItem 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.preferences.DankChatPreferenceStore @@ -42,7 +42,7 @@ import java.util.Locale * suitable for Compose usage where we can pass parameters via koinViewModel(). */ @KoinViewModel -class ChatComposeViewModel( +class ChatViewModel( @InjectedParam private val channel: UserName, private val chatMessageRepository: ChatMessageRepository, private val chatMessageMapper: ChatMessageMapper, @@ -155,7 +155,7 @@ class ChatComposeViewModel( } companion object { - private val TAG = ChatComposeViewModel::class.java.simpleName + private val TAG = ChatViewModel::class.java.simpleName } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt index 54409a7cf..0d75d00c5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteDrawablePainter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteDrawablePainter.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt index a245e2bf7..a1715c238 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteDrawablePainter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import android.graphics.drawable.Drawable import android.os.Handler diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt index bf0094af7..9187fe713 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/EmoteScaling.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt index 65489a481..f00ce7195 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/Linkification.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import android.util.Patterns import androidx.compose.ui.graphics.Color diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/MessageClickEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/MessageClickEvent.kt similarity index 100% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/MessageClickEvent.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/MessageClickEvent.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt index c61a848e1..68d623cbd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt index c3b1c08d7..9b0cc24e5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.ui.chat import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoViewModel.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoViewModel.kt index 242f8a932..ca2032c4c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/compose/EmoteInfoComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoViewModel.kt @@ -1,8 +1,7 @@ -package com.flxrs.dankchat.chat.emote.compose +package com.flxrs.dankchat.ui.chat.emote import androidx.lifecycle.ViewModel import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.emote.EmoteSheetItem import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType @@ -10,7 +9,7 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @KoinViewModel -class EmoteInfoComposeViewModel( +class EmoteInfoViewModel( @InjectedParam private val emotes: List, ) : ViewModel() { 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/EmoteSheetItem.kt similarity index 89% 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/EmoteSheetItem.kt index 8d327411f..222d3e776 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteSheetItem.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.emote +package com.flxrs.dankchat.ui.chat.emote import androidx.annotation.StringRes import com.flxrs.dankchat.data.DisplayName diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteItem.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteItem.kt index f70782230..47ea3742a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteItem.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.emotemenu +package com.flxrs.dankchat.ui.chat.emotemenu import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.twitch.emote.GenericEmote 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 62% 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..f7d00b678 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,4 +1,4 @@ -package com.flxrs.dankchat.chat.emotemenu +package com.flxrs.dankchat.ui.chat.emotemenu enum class EmoteMenuTab { RECENT, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTabItem.kt similarity index 75% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTabItem.kt index d9fd3fd8c..fc32cb509 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTabItem.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.emotemenu +package com.flxrs.dankchat.ui.chat.emotemenu import androidx.compose.runtime.Immutable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt similarity index 90% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt index 612bf5bf9..4aac69653 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/history/compose/MessageHistoryComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.history.compose +package com.flxrs.dankchat.ui.chat.history import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.placeCursorAtEnd @@ -6,14 +6,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.compose.ChatDisplaySettings -import com.flxrs.dankchat.chat.compose.ChatMessageMapper -import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.search.ChatItemFilter -import com.flxrs.dankchat.chat.search.ChatSearchFilter -import com.flxrs.dankchat.chat.search.ChatSearchFilterParser -import com.flxrs.dankchat.chat.search.SearchFilterSuggestions -import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository @@ -22,6 +14,14 @@ import com.flxrs.dankchat.data.twitch.message.PrivMessage 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.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 com.flxrs.dankchat.utils.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -40,7 +40,7 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @KoinViewModel -class MessageHistoryComposeViewModel( +class MessageHistoryViewModel( @InjectedParam private val channel: UserName, chatMessageRepository: ChatMessageRepository, usersRepository: UsersRepository, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionComposable.kt similarity index 86% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionComposable.kt index cd634c2e8..6e4702efb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionComposable.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.mention.compose +package com.flxrs.dankchat.ui.chat.mention import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable @@ -9,25 +9,25 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.LocalPlatformContext import coil3.imageLoader -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatScreen -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator 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.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator /** * Standalone composable for mentions/whispers display. * Extracted from MentionChatFragment to enable pure Compose integration. * * This composable: - * - Collects mentions or whispers from MentionComposeViewModel based on isWhisperTab + * - Collects mentions or whispers from MentionViewModel based on isWhisperTab * - Collects appearance settings * - Renders ChatScreen with channel prefix for mentions only */ @Composable fun MentionComposable( - mentionViewModel: MentionComposeViewModel, + 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, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionViewModel.kt similarity index 92% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionViewModel.kt index b28ad2604..825591e17 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/compose/MentionComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionViewModel.kt @@ -1,15 +1,15 @@ -package com.flxrs.dankchat.chat.mention.compose +package com.flxrs.dankchat.ui.chat.mention import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.compose.ChatDisplaySettings -import com.flxrs.dankchat.chat.compose.ChatMessageMapper -import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.data.chat.ChatItem import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository 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.utils.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel @KoinViewModel -class MentionComposeViewModel( +class MentionViewModel( chatNotificationRepository: ChatNotificationRepository, private val chatMessageMapper: ChatMessageMapper, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsParams.kt similarity index 85% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsParams.kt index 5811f0ee6..a486c8669 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsParams.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsParams.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.message.compose +package com.flxrs.dankchat.ui.chat.message import com.flxrs.dankchat.data.UserName diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsViewModel.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsViewModel.kt index 5b3ed568e..ee3488971 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/compose/MessageOptionsComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.message.compose +package com.flxrs.dankchat.ui.chat.message import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -25,7 +25,7 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @KoinViewModel -class MessageOptionsComposeViewModel( +class MessageOptionsViewModel( @InjectedParam private val messageId: String, @InjectedParam private val channel: UserName?, @InjectedParam private val canModerateParam: Boolean, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/AutomodMessage.kt similarity index 93% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/AutomodMessage.kt index 10e557546..74b77219a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/AutomodMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/AutomodMessage.kt @@ -1,9 +1,8 @@ -package com.flxrs.dankchat.chat.compose.messages +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.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.MaterialTheme @@ -25,15 +24,15 @@ 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.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus -import com.flxrs.dankchat.chat.compose.EmoteDimensions -import com.flxrs.dankchat.chat.compose.messages.common.BadgeInlineContent -import com.flxrs.dankchat.chat.compose.EmoteScaling -import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent -import com.flxrs.dankchat.chat.compose.rememberNormalizedColor -import com.flxrs.dankchat.chat.compose.resolve 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.EmoteDimensions +import com.flxrs.dankchat.ui.chat.EmoteScaling +import com.flxrs.dankchat.ui.chat.TextWithMeasuredInlineContent +import com.flxrs.dankchat.ui.chat.messages.common.BadgeInlineContent +import com.flxrs.dankchat.ui.chat.rememberNormalizedColor +import com.flxrs.dankchat.utils.resolve private val AutoModBlue = Color(0xFF448AFF) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/PrivMessage.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/PrivMessage.kt index 1c893ccda..196591c9f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/PrivMessage.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose.messages +package com.flxrs.dankchat.ui.chat.messages import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -34,19 +34,19 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.LocalPlatformContext -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.appendWithLinks -import com.flxrs.dankchat.chat.compose.messages.common.MessageTextWithInlineContent -import com.flxrs.dankchat.chat.compose.messages.common.launchCustomTab -import com.flxrs.dankchat.chat.compose.messages.common.timestampSpanStyle -import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor -import com.flxrs.dankchat.chat.compose.rememberBackgroundColor -import com.flxrs.dankchat.chat.compose.rememberNormalizedColor -import com.flxrs.dankchat.chat.compose.resolve 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.appendWithLinks +import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab +import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle +import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor +import com.flxrs.dankchat.ui.chat.rememberBackgroundColor +import com.flxrs.dankchat.ui.chat.rememberNormalizedColor +import com.flxrs.dankchat.utils.resolve /** * Renders a regular chat message with: diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/SystemMessages.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/SystemMessages.kt index eebc2845a..f7285cf3c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/SystemMessages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/SystemMessages.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose.messages +package com.flxrs.dankchat.ui.chat.messages import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -23,15 +23,15 @@ 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.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.appendWithLinks -import com.flxrs.dankchat.chat.compose.messages.common.SimpleMessageContainer -import com.flxrs.dankchat.chat.compose.messages.common.launchCustomTab -import com.flxrs.dankchat.chat.compose.messages.common.timestampSpanStyle -import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor -import com.flxrs.dankchat.chat.compose.rememberBackgroundColor -import com.flxrs.dankchat.chat.compose.rememberNormalizedColor -import com.flxrs.dankchat.chat.compose.resolve +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import com.flxrs.dankchat.ui.chat.appendWithLinks +import com.flxrs.dankchat.ui.chat.messages.common.SimpleMessageContainer +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab +import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle +import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor +import com.flxrs.dankchat.ui.chat.rememberBackgroundColor +import com.flxrs.dankchat.ui.chat.rememberNormalizedColor +import com.flxrs.dankchat.utils.resolve /** * Renders a system message (connected, disconnected, emote loading failures, etc.) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt index 26a5c3fb4..3ad1e8179 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/WhisperAndRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose.messages +package com.flxrs.dankchat.ui.chat.messages import androidx.compose.foundation.background import androidx.compose.foundation.indication @@ -34,17 +34,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatMessageUiState -import com.flxrs.dankchat.chat.compose.appendWithLinks -import com.flxrs.dankchat.chat.compose.messages.common.MessageTextWithInlineContent -import com.flxrs.dankchat.chat.compose.messages.common.launchCustomTab -import com.flxrs.dankchat.chat.compose.messages.common.timestampSpanStyle -import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor -import com.flxrs.dankchat.chat.compose.rememberBackgroundColor -import com.flxrs.dankchat.chat.compose.rememberNormalizedColor 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.appendWithLinks +import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab +import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle +import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor +import com.flxrs.dankchat.ui.chat.rememberBackgroundColor +import com.flxrs.dankchat.ui.chat.rememberNormalizedColor /** * Renders a whisper message (private message between users) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt similarity index 83% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt index 86ddd3af4..98bdaf920 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/InlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose.messages.common +package com.flxrs.dankchat.ui.chat.messages.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -9,11 +9,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImage -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.EmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.EmoteUi -import com.flxrs.dankchat.chat.compose.StackedEmote import com.flxrs.dankchat.data.twitch.badge.Badge +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.EmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.EmoteUi +import com.flxrs.dankchat.ui.chat.StackedEmote private val FfzModGreen = Color(0xFF34AE0A) @@ -29,7 +29,9 @@ fun BadgeInlineContent( ) { when (badge.badge) { is Badge.FFZModBadge -> { - Box(modifier = modifier.size(size).background(FfzModGreen)) { + Box(modifier = modifier + .size(size) + .background(FfzModGreen)) { AsyncImage( model = badge.url, contentDescription = badge.badge.type.name, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt index 5da7d1d9f..726d0d757 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose.messages.common +package com.flxrs.dankchat.ui.chat.messages.common import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Composable @@ -11,13 +11,13 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.EmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.EmoteScaling -import com.flxrs.dankchat.chat.compose.EmoteUi import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.EmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.EmoteScaling +import com.flxrs.dankchat.ui.chat.EmoteUi /** * Appends a formatted timestamp to the AnnotatedString builder. diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextRenderer.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextRenderer.kt similarity index 91% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextRenderer.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextRenderer.kt index ad42ee76a..0b132692d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/MessageTextRenderer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextRenderer.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose.messages.common +package com.flxrs.dankchat.ui.chat.messages.common import android.content.Context import android.util.Log @@ -18,14 +18,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.core.net.toUri -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.EmoteDimensions -import com.flxrs.dankchat.chat.compose.EmoteScaling -import com.flxrs.dankchat.chat.compose.EmoteUi -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.StackedEmote -import com.flxrs.dankchat.chat.compose.TextWithMeasuredInlineContent import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.EmoteDimensions +import com.flxrs.dankchat.ui.chat.EmoteScaling +import com.flxrs.dankchat.ui.chat.EmoteUi +import com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.StackedEmote +import com.flxrs.dankchat.ui.chat.TextWithMeasuredInlineContent import kotlinx.collections.immutable.ImmutableList @Composable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/SimpleMessageContainer.kt similarity index 91% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/SimpleMessageContainer.kt index f2ab849ba..fc187bc01 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/messages/common/SimpleMessageContainer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/SimpleMessageContainer.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose.messages.common +package com.flxrs.dankchat.ui.chat.messages.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -19,9 +19,9 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.chat.compose.appendWithLinks -import com.flxrs.dankchat.chat.compose.rememberAdaptiveTextColor -import com.flxrs.dankchat.chat.compose.rememberBackgroundColor +import com.flxrs.dankchat.ui.chat.appendWithLinks +import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor +import com.flxrs.dankchat.ui.chat.rememberBackgroundColor /** * A simple message container for system messages, notices, and other simple message types. diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesComposable.kt similarity index 85% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesComposable.kt index b6e35d114..766c5f4b7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesComposable.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.replies.compose +package com.flxrs.dankchat.ui.chat.replies import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable @@ -10,25 +10,24 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.LocalPlatformContext import coil3.imageLoader -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatScreen -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.replies.RepliesUiState +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ChatScreen +import com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator /** * Standalone composable for reply thread display. * Extracted from RepliesChatFragment to enable pure Compose integration. * * This composable: - * - Collects reply thread state from RepliesComposeViewModel + * - Collects reply thread state from RepliesViewModel * - Collects appearance settings * - Handles NotFound state via onNotFound callback * - Renders ChatScreen for Found state */ @Composable fun RepliesComposable( - repliesViewModel: RepliesComposeViewModel, + repliesViewModel: RepliesViewModel, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onNotFound: () -> Unit, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesState.kt similarity index 71% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesState.kt index abc4f1d49..948ddfc30 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesState.kt @@ -1,8 +1,8 @@ -package com.flxrs.dankchat.chat.replies +package com.flxrs.dankchat.ui.chat.replies import androidx.compose.runtime.Immutable -import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.compose.ChatMessageUiState +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.ui.chat.ChatMessageUiState @Immutable sealed interface RepliesState { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesViewModel.kt similarity index 90% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesViewModel.kt index 7a4d35a71..7e30dbe67 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/compose/RepliesComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesViewModel.kt @@ -1,15 +1,13 @@ -package com.flxrs.dankchat.chat.replies.compose +package com.flxrs.dankchat.ui.chat.replies import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.compose.ChatDisplaySettings -import com.flxrs.dankchat.chat.compose.ChatMessageMapper -import com.flxrs.dankchat.chat.replies.RepliesState -import com.flxrs.dankchat.chat.replies.RepliesUiState import com.flxrs.dankchat.data.repo.RepliesRepository 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.utils.extensions.isEven import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -22,7 +20,7 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @KoinViewModel -class RepliesComposeViewModel( +class RepliesViewModel( @InjectedParam private val rootMessageId: String, repliesRepository: RepliesRepository, private val chatMessageMapper: ChatMessageMapper, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatItemFilter.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatItemFilter.kt index 740154a8f..7eae4e51b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatItemFilter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatItemFilter.kt @@ -1,6 +1,6 @@ -package com.flxrs.dankchat.chat.search +package com.flxrs.dankchat.ui.chat.search -import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.data.chat.ChatItem import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilter.kt similarity index 93% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilter.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilter.kt index ed52d8ec7..8ca9d8689 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilter.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.search +package com.flxrs.dankchat.ui.chat.search import androidx.compose.runtime.Immutable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilterParser.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilterParser.kt index 8e6ad804b..e946c8217 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/ChatSearchFilterParser.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilterParser.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.search +package com.flxrs.dankchat.ui.chat.search object ChatSearchFilterParser { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/SearchFilterSuggestions.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/SearchFilterSuggestions.kt index 278f34370..14d43bfe3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/search/SearchFilterSuggestions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/SearchFilterSuggestions.kt @@ -1,8 +1,8 @@ -package com.flxrs.dankchat.chat.search +package com.flxrs.dankchat.ui.chat.search import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.suggestion.Suggestion import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion object SearchFilterSuggestions { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/Suggestion.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/Suggestion.kt index 924e0f722..5306d60d0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/Suggestion.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.suggestion +package com.flxrs.dankchat.ui.chat.suggestion import androidx.annotation.StringRes import com.flxrs.dankchat.data.DisplayName diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt index fa0f2a38e..7e82f9142 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProvider.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.suggestion +package com.flxrs.dankchat.ui.chat.suggestion import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.chat.UsersRepository diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupDialog.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupDialog.kt index 4069296c0..d88d42545 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/compose/UserPopupDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.user.compose +package com.flxrs.dankchat.ui.chat.user import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -50,10 +50,9 @@ 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.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.user.UserPopupState import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.ui.chat.BadgeUi @OptIn(ExperimentalMaterial3Api::class) @Composable 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 94% 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 3e6006662..0cb4bbb5b 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,4 +1,4 @@ -package com.flxrs.dankchat.chat.user +package com.flxrs.dankchat.ui.chat.user import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupStateParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupStateParams.kt similarity index 92% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupStateParams.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupStateParams.kt index ac1171de1..ff51e0bc2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupStateParams.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupStateParams.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.user +package com.flxrs.dankchat.ui.chat.user import android.os.Parcelable import com.flxrs.dankchat.data.DisplayName diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupComposeViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupComposeViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt index 50cc8b71b..390e0bca9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupComposeViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.user +package com.flxrs.dankchat.ui.chat.user import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -21,7 +21,7 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @KoinViewModel -class UserPopupComposeViewModel( +class UserPopupViewModel( @InjectedParam private val params: UserPopupStateParams, private val dataRepository: DataRepository, private val ignoresRepository: IgnoresRepository, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt index a88fdc4b8..d81ca7cdb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/compose/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.login.compose +package com.flxrs.dankchat.ui.login import android.annotation.SuppressLint import android.graphics.Bitmap @@ -34,7 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import com.flxrs.dankchat.R -import com.flxrs.dankchat.login.LoginViewModel import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt index e0976df82..edb2312e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt @@ -1,11 +1,11 @@ -package com.flxrs.dankchat.login +package com.flxrs.dankchat.ui.login import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.auth.AuthDataStore 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 kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DraggableHandle.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/DraggableHandle.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt index 600bf79b1..1b1dbf249 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DraggableHandle.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt index d49e491ed..c8af2cc85 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt index 80444dcc9..c544bab21 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing @@ -24,13 +24,6 @@ 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.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.unit.Constraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -77,16 +70,24 @@ 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.layout 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.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.data.UserName +import com.flxrs.dankchat.ui.main.channel.ChannelTabUiState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt similarity index 87% rename from app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt index fa656ecff..24e6cbb07 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main +package com.flxrs.dankchat.ui.main import androidx.compose.runtime.Stable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt index e757bd5c0..d648ef7ca 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -1,10 +1,8 @@ -package com.flxrs.dankchat.main +package com.flxrs.dankchat.ui.main import android.Manifest import android.annotation.SuppressLint -import android.app.Activity import android.content.ComponentName -import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.net.Uri @@ -42,17 +40,11 @@ import androidx.navigation.compose.rememberNavController import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.DankChatViewModel import com.flxrs.dankchat.R -import com.flxrs.dankchat.changelog.ChangelogScreen import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.ApiException 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.login.compose.LoginScreen -import com.flxrs.dankchat.main.compose.MainEventBus -import com.flxrs.dankchat.main.compose.MainScreen -import com.flxrs.dankchat.onboarding.OnboardingDataStore -import com.flxrs.dankchat.onboarding.OnboardingScreen import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.about.AboutScreen import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsScreen @@ -69,7 +61,11 @@ 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.theme.DankChatTheme +import com.flxrs.dankchat.ui.changelog.ChangelogScreen +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 @@ -323,7 +319,7 @@ class MainActivity : AppCompatActivity() { ) { OverviewSettingsScreen( isLoggedIn = isLoggedIn, - hasChangelog = com.flxrs.dankchat.changelog.DankChatVersion.HAS_CHANGELOG, + hasChangelog = com.flxrs.dankchat.ui.changelog.DankChatVersion.HAS_CHANGELOG, onBackPressed = { navController.popBackStack() }, onLogoutRequested = { lifecycleScope.launch { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt index 7cb6513fc..adcb2ea43 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent @@ -9,39 +9,36 @@ 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.Column -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.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.DeleteSweep 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.automirrored.filled.Login -import androidx.compose.material.icons.automirrored.filled.Logout 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.EmojiEmotions import androidx.compose.material.icons.filled.Videocam -import androidx.compose.material.icons.filled.Autorenew -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -55,11 +52,14 @@ 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.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CancellationException import com.flxrs.dankchat.R +import kotlinx.coroutines.CancellationException sealed interface AppBarMenu { data object Main : AppBarMenu @@ -119,7 +119,7 @@ fun InlineOverflowMenu( .padding(vertical = 8.dp), ) { when (menu) { - AppBarMenu.Main -> { + AppBarMenu.Main -> { if (!isLoggedIn) { InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { onAction(ToolbarAction.Login); onDismiss() } } else { @@ -144,7 +144,7 @@ fun InlineOverflowMenu( InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { onAction(ToolbarAction.OpenSettings); onDismiss() } } - AppBarMenu.Upload -> { + AppBarMenu.Upload -> { InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { onAction(ToolbarAction.CaptureImage); onDismiss() } InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { onAction(ToolbarAction.CaptureVideo); onDismiss() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt index ede0c93e2..8c45f119a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainDestination.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main +package com.flxrs.dankchat.ui.main import kotlinx.serialization.Serializable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt index 753b7a190..aa0a2480e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main +package com.flxrs.dankchat.ui.main import com.flxrs.dankchat.data.UserName import java.io.File diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt similarity index 89% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt index f3370a537..279d49413 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainEventBus.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt @@ -1,6 +1,5 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main -import com.flxrs.dankchat.main.MainEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt index fa08be298..f81cbad1e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import android.app.Activity import android.app.PictureInPictureParams @@ -55,7 +55,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo - import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -98,24 +97,39 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.window.core.layout.WindowSizeClass import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.ChatComposable -import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker -import com.flxrs.dankchat.chat.compose.swipeDownToHide -import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.main.compose.sheets.EmoteMenu import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.InputAction import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore -import com.flxrs.dankchat.tour.FeatureTourViewModel -import com.flxrs.dankchat.tour.PostOnboardingStep -import com.flxrs.dankchat.tour.TourStep +import com.flxrs.dankchat.ui.chat.ChatComposable +import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import com.flxrs.dankchat.ui.chat.mention.MentionViewModel +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.swipeDownToHide +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams +import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel +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.CharacterCounterState +import com.flxrs.dankchat.ui.main.input.ChatBottomBar +import com.flxrs.dankchat.ui.main.input.ChatInputViewModel +import com.flxrs.dankchat.ui.main.input.SuggestionDropdown +import com.flxrs.dankchat.ui.main.input.TourOverlayState +import com.flxrs.dankchat.ui.main.sheet.EmoteMenu +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.FeatureTourViewModel +import com.flxrs.dankchat.ui.tour.PostOnboardingStep +import com.flxrs.dankchat.ui.tour.TourStep import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding -import com.flxrs.dankchat.preferences.appearance.InputAction import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce @@ -159,7 +173,7 @@ fun MainScreen( val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() val streamViewModel: StreamViewModel = koinViewModel() val dialogViewModel: DialogStateViewModel = koinViewModel() - val mentionViewModel: MentionComposeViewModel = koinViewModel() + val mentionViewModel: MentionViewModel = koinViewModel() val preferenceStore: DankChatPreferenceStore = koinInject() val mainEventBus: MainEventBus = koinInject() val featureTourViewModel: FeatureTourViewModel = koinViewModel() @@ -652,6 +666,7 @@ fun MainScreen( sheetNavigationViewModel.openMentions() channelTabViewModel.clearAllMentionCounts() } + ToolbarAction.Login -> onLogin() ToolbarAction.Relogin -> onRelogin() ToolbarAction.Logout -> dialogViewModel.showLogout() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt index 8c315e92e..585e93e5b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import android.content.ClipData import android.content.ClipboardManager @@ -13,14 +13,15 @@ import androidx.compose.ui.platform.LocalResources import androidx.core.content.getSystemService import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R -import com.flxrs.dankchat.auth.AuthEvent -import com.flxrs.dankchat.auth.AuthStateCoordinator +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.data.state.GlobalLoadingState -import com.flxrs.dankchat.main.MainActivity -import com.flxrs.dankchat.main.MainEvent 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 kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.koinInject diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt index 04d1aeded..0169531e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt index 8513f90a1..89a3cc5ae 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement @@ -48,6 +48,7 @@ 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 kotlinx.collections.immutable.ImmutableList /** diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/RepeatedSendData.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt similarity index 66% rename from app/src/main/kotlin/com/flxrs/dankchat/main/RepeatedSendData.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt index edc46028e..e0f019368 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/RepeatedSendData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt @@ -1,3 +1,3 @@ -package com.flxrs.dankchat.main +package com.flxrs.dankchat.ui.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/compose/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt index d61fe7d2c..7960e3d78 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.channel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelPagerViewModel.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelPagerViewModel.kt index f75e84a93..422e68211 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelPagerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelPagerViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.channel import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTab.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTab.kt index 18e3e4b5f..a4767e8aa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTab.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTab.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.channel import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt index 437c7163d..7064c907f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabRow.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.channel import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.material3.ExperimentalMaterial3Api diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabViewModel.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabViewModel.kt index 27118767a..91438402a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChannelTabViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.channel import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/AddChannelDialog.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/AddChannelDialog.kt index 21335b432..3d2b438f0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/AddChannelDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/AddChannelDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ConfirmationDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ConfirmationDialog.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ConfirmationDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ConfirmationDialog.kt index 00a454e4e..437223459 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ConfirmationDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ConfirmationDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogStateViewModel.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogStateViewModel.kt index 5ab6d2e06..ecf3b0f73 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/DialogStateViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogStateViewModel.kt @@ -1,12 +1,12 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.user.UserPopupStateParams import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EditChannelDialog.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EditChannelDialog.kt index 83e95aa81..d513ee639 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EditChannelDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EditChannelDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EmoteInfoDialog.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EmoteInfoDialog.kt index 0c9360b9f..1ea8ec102 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/EmoteInfoDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EmoteInfoDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -36,7 +36,7 @@ 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.chat.emote.EmoteSheetItem +import com.flxrs.dankchat.ui.chat.emote.EmoteSheetItem import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt similarity index 91% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt index 04806646b..309771d61 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.dialog import android.content.ClipData import androidx.compose.material3.AlertDialog @@ -15,23 +15,22 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.emote.compose.EmoteInfoComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsState -import com.flxrs.dankchat.chat.user.UserPopupComposeViewModel -import com.flxrs.dankchat.chat.user.compose.UserPopupDialog import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.main.compose.dialogs.AddChannelDialog -import com.flxrs.dankchat.main.compose.dialogs.ConfirmationDialog -import com.flxrs.dankchat.main.compose.dialogs.EmoteInfoDialog -import com.flxrs.dankchat.main.compose.dialogs.ManageChannelsDialog -import com.flxrs.dankchat.main.compose.dialogs.MessageOptionsDialog -import com.flxrs.dankchat.main.compose.dialogs.MoreActionsSheet -import com.flxrs.dankchat.main.compose.dialogs.RoomStateDialog +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.emote.EmoteInfoViewModel +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.UserPopupViewModel +import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel +import com.flxrs.dankchat.ui.main.input.ChatInputViewModel +import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState +import com.flxrs.dankchat.ui.main.sheet.InputSheetState +import com.flxrs.dankchat.ui.main.sheet.MoreActionsSheet +import com.flxrs.dankchat.ui.main.sheet.SheetNavigationViewModel import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -183,7 +182,7 @@ fun MainScreenDialogs( } dialogState.messageOptionsParams?.let { params -> - val viewModel: MessageOptionsComposeViewModel = koinViewModel( + val viewModel: MessageOptionsViewModel = koinViewModel( key = params.messageId, parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } ) @@ -228,7 +227,7 @@ fun MainScreenDialogs( } dialogState.emoteInfoEmotes?.let { emotes -> - val viewModel: EmoteInfoComposeViewModel = koinViewModel( + val viewModel: EmoteInfoViewModel = koinViewModel( key = emotes.joinToString { it.id }, parameters = { parametersOf(emotes) } ) @@ -237,8 +236,10 @@ fun MainScreenDialogs( 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( @@ -256,7 +257,7 @@ fun MainScreenDialogs( } dialogState.userPopupParams?.let { params -> - val viewModel: UserPopupComposeViewModel = koinViewModel( + val viewModel: UserPopupViewModel = koinViewModel( key = "${params.targetUserId}${params.channel?.value.orEmpty()}", parameters = { parametersOf(params) } ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt index f9287baee..bf3798c80 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/ManageChannelsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.ExperimentalFoundationApi diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MessageOptionsDialog.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MessageOptionsDialog.kt index 5b98f7706..fa85fcdc9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MessageOptionsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MessageOptionsDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/RoomStateDialog.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/RoomStateDialog.kt index 4c5e17478..723b76eca 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/RoomStateDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/RoomStateDialog.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.dialog import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn @@ -48,6 +48,7 @@ private enum class ParameterDialogType { } private val SLOW_MODE_PRESETS = listOf(3, 5, 10, 20, 30, 60, 120) + private data class FollowerPreset(val minutes: Int, val commandArg: String) private val FOLLOWER_MODE_PRESETS = listOf( @@ -63,12 +64,12 @@ private val FOLLOWER_MODE_PRESETS = listOf( @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) + 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) + else -> stringResource(R.string.room_state_duration_months, minutes / 43200) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -84,7 +85,7 @@ fun RoomStateDialog( parameterDialog?.let { type -> val (titleRes, hintRes, defaultValue, commandPrefix) = when (type) { - ParameterDialogType.SLOW_MODE -> ParameterDialogConfig( + ParameterDialogType.SLOW_MODE -> ParameterDialogConfig( titleRes = R.string.room_state_slow_mode, hintRes = R.string.seconds, defaultValue = "30", @@ -153,14 +154,14 @@ fun RoomStateDialog( label = "RoomStateContent" ) { currentView -> when (currentView) { - null -> RoomStateModeChips( + null -> RoomStateModeChips( roomState = roomState, onSendCommand = onSendCommand, onShowPresets = { presetsView = it }, onDismiss = onDismiss, ) - ParameterDialogType.SLOW_MODE -> PresetChips( + ParameterDialogType.SLOW_MODE -> PresetChips( titleRes = R.string.room_state_slow_mode, presets = SLOW_MODE_PRESETS, formatLabel = { stringResource(R.string.room_state_duration_seconds, it) }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatBottomBar.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatBottomBar.kt index e7718892c..c754eb7b3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatBottomBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatBottomBar.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.input import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition @@ -123,6 +123,7 @@ fun ChatBottomBar( val direction = LocalLayoutDirection.current PaddingValues(start = 16.dp, end = rcPadding.calculateEndPadding(direction)) } + isFullscreen -> rememberRoundedCornerHorizontalPadding(fallback = 16.dp) else -> PaddingValues(horizontal = 16.dp) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt index 88db28823..4e9cfe83f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputLayout.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.input import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState @@ -34,7 +34,6 @@ import androidx.compose.material.icons.filled.AddComment 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.Edit import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit @@ -90,8 +89,9 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.main.InputState import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.ui.main.InputState +import com.flxrs.dankchat.ui.main.QuickActionsMenu import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import sh.calvin.reorderable.ReorderableColumn diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt index 21742dc50..88da55d9f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/ChatInputViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.input import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText @@ -9,8 +9,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.suggestion.Suggestion -import com.flxrs.dankchat.chat.suggestion.SuggestionProvider import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.repo.channel.ChannelRepository @@ -24,15 +22,19 @@ 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.main.InputState -import com.flxrs.dankchat.main.MainEvent -import com.flxrs.dankchat.main.RepeatedSendData 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.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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -365,7 +367,7 @@ class ChatInputViewModel( chatRepository.fakeWhisperIfNecessary(message) } val isWhisperContext = chatState is FullScreenSheetState.Whisper || - (chatState is FullScreenSheetState.Mention && _whisperTarget.value != null) + (chatState is FullScreenSheetState.Mention && _whisperTarget.value != null) if (commandResult.response != null && !isWhisperContext) { chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt index 02753573e..94012b04d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SuggestionDropdown.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.input import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize @@ -34,7 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import com.flxrs.dankchat.chat.suggestion.Suggestion +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion import kotlinx.collections.immutable.ImmutableList @Composable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt index 662e1d088..13b82e022 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.sheets +package com.flxrs.dankchat.ui.main.sheet import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -42,10 +42,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.emotemenu.EmoteItem -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab -import com.flxrs.dankchat.main.compose.EmoteMenuViewModel import com.flxrs.dankchat.preferences.components.DankBackground +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenuTab import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt index c853789dc..2e4fc543c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/EmoteMenuSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.sheets +package com.flxrs.dankchat.ui.main.sheet import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -31,9 +31,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.emotemenu.EmoteItem -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab -import com.flxrs.dankchat.main.compose.EmoteMenuViewModel +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenuTab import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuViewModel.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuViewModel.kt index 5e526b3ee..1603c0822 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/EmoteMenuViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuViewModel.kt @@ -1,14 +1,14 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.sheet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTabItem 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.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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt similarity index 91% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt index b570a9398..913143bd8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/FullScreenSheetOverlay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.sheet import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -12,18 +12,15 @@ 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.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.history.compose.MessageHistoryComposeViewModel -import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel -import com.flxrs.dankchat.chat.message.compose.MessageOptionsParams -import com.flxrs.dankchat.chat.user.UserPopupStateParams 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.main.compose.sheets.MentionSheet -import com.flxrs.dankchat.main.compose.sheets.MessageHistorySheet -import com.flxrs.dankchat.main.compose.sheets.RepliesSheet +import com.flxrs.dankchat.ui.chat.BadgeUi +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 @@ -31,7 +28,7 @@ import org.koin.core.parameter.parametersOf fun FullScreenSheetOverlay( sheetState: FullScreenSheetState, isLoggedIn: Boolean, - mentionViewModel: MentionComposeViewModel, + mentionViewModel: MentionViewModel, onDismiss: () -> Unit, onDismissReplies: () -> Unit, onUserClick: (UserPopupStateParams) -> Unit, @@ -142,7 +139,7 @@ fun FullScreenSheetOverlay( } is FullScreenSheetState.History -> { - val viewModel: MessageHistoryComposeViewModel = koinViewModel( + val viewModel: MessageHistoryViewModel = koinViewModel( key = sheetState.channel.value, parameters = { parametersOf(sheetState.channel) }, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MentionSheet.kt similarity index 96% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MentionSheet.kt index 127312757..f3ce77348 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MentionSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MentionSheet.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.sheets +package com.flxrs.dankchat.ui.main.sheet import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility @@ -15,8 +15,8 @@ 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.height 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.pager.HorizontalPager @@ -47,18 +47,18 @@ 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.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker -import com.flxrs.dankchat.chat.mention.compose.MentionComposable -import com.flxrs.dankchat.chat.mention.compose.MentionComposeViewModel 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: MentionComposeViewModel, + mentionViewModel: MentionViewModel, initialisWhisperTab: Boolean, onDismiss: () -> Unit, onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt index 93fb08e67..c4c04a737 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/MessageHistorySheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.sheets +package com.flxrs.dankchat.ui.main.sheet import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility @@ -14,8 +14,8 @@ 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.height 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 @@ -62,21 +62,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.LocalPlatformContext import coil3.imageLoader import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker -import com.flxrs.dankchat.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ChatScreen -import com.flxrs.dankchat.chat.compose.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.compose.rememberEmoteAnimationCoordinator -import com.flxrs.dankchat.chat.history.compose.MessageHistoryComposeViewModel import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.main.compose.SuggestionDropdown +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ChatScreen +import com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import com.flxrs.dankchat.ui.chat.history.MessageHistoryViewModel +import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.main.input.SuggestionDropdown import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException @Composable fun MessageHistorySheet( - viewModel: MessageHistoryComposeViewModel, + viewModel: MessageHistoryViewModel, channel: UserName, initialFilter: String, onDismiss: () -> Unit, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MoreActionsSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MoreActionsSheet.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MoreActionsSheet.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MoreActionsSheet.kt index 6f002f07d..a479387f7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/dialogs/MoreActionsSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MoreActionsSheet.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.dialogs +package com.flxrs.dankchat.ui.main.sheet import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt index f738cd2a3..482c7c882 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/sheets/RepliesSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose.sheets +package com.flxrs.dankchat.ui.main.sheet import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility @@ -12,8 +12,8 @@ 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.height 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 @@ -40,10 +40,10 @@ 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.chat.compose.BadgeUi -import com.flxrs.dankchat.chat.compose.ScrollDirectionTracker -import com.flxrs.dankchat.chat.replies.compose.RepliesComposable -import com.flxrs.dankchat.chat.replies.compose.RepliesComposeViewModel +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 @@ -56,7 +56,7 @@ fun RepliesSheet( onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, bottomContentPadding: Dp = 0.dp, ) { - val viewModel: RepliesComposeViewModel = koinViewModel( + val viewModel: RepliesViewModel = koinViewModel( key = rootMessageId, parameters = { parametersOf(rootMessageId) } ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationViewModel.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationViewModel.kt index 909b904dd..359f53415 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/SheetNavigationViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.sheet import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel @@ -78,10 +78,12 @@ class SheetNavigationViewModel : ViewModel() { sealed interface FullScreenSheetState { data object Closed : FullScreenSheetState + @Immutable data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState data object Mention : FullScreenSheetState data object Whisper : FullScreenSheetState + @Immutable data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState } @@ -89,6 +91,7 @@ sealed interface FullScreenSheetState { sealed interface InputSheetState { data object Closed : InputSheetState data object EmoteMenu : InputSheetState + @Immutable data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamView.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamView.kt index ce822bc0d..78ef6a9b9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamView.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.stream import android.view.ViewGroup import android.webkit.WebResourceRequest diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamViewModel.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamViewModel.kt index f47220bad..a04a290d3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/compose/StreamViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamViewModel.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main.stream import android.annotation.SuppressLint import android.app.Application @@ -8,7 +8,6 @@ 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.main.stream.StreamWebView import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -107,7 +106,6 @@ class StreamViewModel( _currentStreamedChannel.value = null } - override fun onCleared() { streamDataRepository.cancelStreamData() cachedWebView?.destroy() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebView.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamWebView.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebView.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamWebView.kt index 42aec0b94..8c81f8585 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebView.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamWebView.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.main.stream +package com.flxrs.dankchat.ui.main.stream import android.annotation.SuppressLint import android.content.Context diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt index 4fe82a16d..135338624 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.onboarding +package com.flxrs.dankchat.ui.onboarding import android.content.Context import androidx.datastore.core.DataMigration diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt index 7758129bf..83e16a74e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.onboarding +package com.flxrs.dankchat.ui.onboarding import android.Manifest import android.annotation.SuppressLint diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt similarity index 89% rename from app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingSettings.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt index 96131725a..280694787 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.onboarding +package com.flxrs.dankchat.ui.onboarding import kotlinx.serialization.Serializable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt index 550ebc50e..cb2c5ee29 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt @@ -1,8 +1,8 @@ -package com.flxrs.dankchat.onboarding +package com.flxrs.dankchat.ui.onboarding import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.auth.AuthDataStore +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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt index 4efbe4c33..eb55536c2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.share +package com.flxrs.dankchat.ui.share import android.content.ClipData import android.content.ClipboardManager @@ -46,7 +46,7 @@ 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.theme.DankChatTheme +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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt index 41bf71657..386d8e9c2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.theme +package com.flxrs.dankchat.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme diff --git a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt index caa5475fd..a75cedc6e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt @@ -1,12 +1,12 @@ -package com.flxrs.dankchat.tour +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.onboarding.OnboardingDataStore -import com.flxrs.dankchat.onboarding.OnboardingSettings +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 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 0054d9b6d..af6ab08a3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt @@ -88,7 +88,8 @@ object DateTimeUtils { } fun decomposeSeconds(totalSeconds: Int): List = buildList { - val mins = totalSeconds / 60; val secs = totalSeconds % 60 + val mins = totalSeconds / 60 + val secs = totalSeconds % 60 if (mins > 0) add(DurationPart(mins, DurationUnit.MINUTES)) if (secs > 0) add(DurationPart(secs, DurationUnit.SECONDS)) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt rename to app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt index 31b5cf24f..f0b7bf6e2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/compose/TextResource.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.compose +package com.flxrs.dankchat.utils import androidx.annotation.PluralsRes import androidx.annotation.StringRes 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 index 61d5670c4..bfd861175 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt @@ -1,6 +1,6 @@ package com.flxrs.dankchat.utils.extensions -import com.flxrs.dankchat.chat.ChatItem +import com.flxrs.dankchat.data.chat.ChatItem fun List.addAndLimit(item: ChatItem, scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit): List = toMutableList().apply { add(item) 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 2764b71f5..f17dd5c5a 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 @@ -5,9 +5,9 @@ import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.content.ContextCompat -import com.flxrs.dankchat.chat.emotemenu.EmoteItem import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.GenericEmote +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem import kotlinx.serialization.json.Json fun List?.toEmoteItems(): List = this 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 index 77e6e3d9b..8994331d6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -1,7 +1,7 @@ package com.flxrs.dankchat.utils.extensions -import com.flxrs.dankchat.chat.ChatImportance -import com.flxrs.dankchat.chat.ChatItem +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 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 index 816848daf..f74cd2af5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt @@ -1,6 +1,6 @@ package com.flxrs.dankchat.utils.extensions -import com.flxrs.dankchat.chat.ChatItem +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 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..3ece4e7d6 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,7 +3,6 @@ package com.flxrs.dankchat.data.irc import org.junit.jupiter.api.Test import kotlin.test.assertEquals - internal class IrcMessageTest { // examples from https://github.com/robotty/twitch-irc-rs @@ -16,10 +15,10 @@ 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) } @@ -32,9 +31,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 +121,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 +139,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 +157,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 +178,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 +197,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/chat/suggestion/SuggestionProviderExtractWordTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProviderExtractWordTest.kt similarity index 98% rename from app/src/test/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProviderExtractWordTest.kt rename to app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProviderExtractWordTest.kt index 61e69ae49..aceb861f7 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionProviderExtractWordTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProviderExtractWordTest.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.chat.suggestion +package com.flxrs.dankchat.ui.chat.suggestion import io.mockk.mockk import org.junit.jupiter.api.Test diff --git a/app/src/test/kotlin/com/flxrs/dankchat/main/compose/SuggestionReplacementTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt similarity index 96% rename from app/src/test/kotlin/com/flxrs/dankchat/main/compose/SuggestionReplacementTest.kt rename to app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt index ac024c535..3c01bef4c 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/main/compose/SuggestionReplacementTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt @@ -1,5 +1,7 @@ -package com.flxrs.dankchat.main.compose +package com.flxrs.dankchat.ui.main +import com.flxrs.dankchat.ui.main.input.computeSuggestionReplacement +import com.flxrs.dankchat.ui.main.input.SuggestionReplacementResult import org.junit.jupiter.api.Test import kotlin.test.assertEquals diff --git a/app/src/test/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModelTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt similarity index 98% rename from app/src/test/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModelTest.kt rename to app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt index 435457d73..0d10b8cda 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/tour/FeatureTourViewModelTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt @@ -1,8 +1,8 @@ -package com.flxrs.dankchat.tour +package com.flxrs.dankchat.ui.tour import app.cash.turbine.test -import com.flxrs.dankchat.onboarding.OnboardingDataStore -import com.flxrs.dankchat.onboarding.OnboardingSettings +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 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 0c94c6d48..37cb90152 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt @@ -10,7 +10,6 @@ import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNull - internal class DateTimeUtilsTest { @Test @@ -180,4 +179,4 @@ internal class DateTimeUtilsTest { val result = DateTimeUtils.decomposeSeconds(0) assertEquals(expected = emptyList(), actual = result) } -} \ No newline at end of file +} From cb7514ab551dd290d77007856b8c333891b37f3e Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 17:02:32 +0100 Subject: [PATCH 107/349] fix(compose): Circular shared chat badges, username click behavior setting, and centered tab scrolling --- .../preferences/chat/ChatSettingsDataStore.kt | 3 ++ .../ui/chat/TextWithMeasuredInlineContent.kt | 18 +++++-- .../chat/history/MessageHistoryViewModel.kt | 7 +++ .../dankchat/ui/chat/messages/PrivMessage.kt | 23 +++++++- .../ui/chat/messages/WhisperAndRedemption.kt | 22 +++++++- .../ui/chat/messages/common/InlineContent.kt | 14 ++++- .../messages/common/MessageTextRenderer.kt | 2 +- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 20 ++++++- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 30 +++++++---- .../ui/main/input/ChatInputViewModel.kt | 6 ++- .../ui/main/sheet/FullScreenSheetOverlay.kt | 52 +++++++++++++++++-- 11 files changed, 171 insertions(+), 26 deletions(-) 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 ae789c481..46766cb17 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 @@ -154,6 +154,9 @@ class ChatSettingsDataStore( val showChatModes = settings .map { it.showChatModes } .distinctUntilChanged() + val userLongClickBehavior = settings + .map { it.userLongClickBehavior } + .distinctUntilChanged() val debouncedScrollBack = settings .map { it.scrollbackLength } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt index 9b0cc24e5..535bfdd61 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt @@ -62,7 +62,7 @@ fun TextWithMeasuredInlineContent( style: TextStyle = TextStyle.Default, knownDimensions: Map = emptyMap(), onTextClick: ((Int) -> Unit)? = null, - onTextLongClick: (() -> Unit)? = null, + onTextLongClick: ((Int) -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { val density = LocalDensity.current @@ -143,8 +143,20 @@ fun TextWithMeasuredInlineContent( } } }, - onLongPress = { - onTextLongClick?.invoke() + 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) + } } ) }, 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 index 4aac69653..daed952b7 100644 --- 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 @@ -126,6 +126,13 @@ class MessageHistoryViewModel( } } + fun insertText(text: String) { + searchFieldState.edit { + append(text) + placeCursorAtEnd() + } + } + fun applySuggestion(suggestion: Suggestion) { val currentText = searchFieldState.text.toString() val lastSpaceIndex = currentText.trimEnd().lastIndexOf(' ') 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 index 196591c9f..dc2e29c3d 100644 --- 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 @@ -289,8 +289,27 @@ private fun PrivMessageText( launchCustomTab(context, annotation.item) } }, - onTextLongClick = { - onMessageLongClick(message.id, message.channel.value, message.fullMessage) + onTextLongClick = { offset -> + val userAnnotation = if (offset >= 0) { + annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + } else { + null + } + + when { + userAnnotation != null -> { + val parts = userAnnotation.item.split("|") + if (parts.size == 4) { + val userId = parts[0].takeIf { it.isNotEmpty() } + val userName = parts[1] + val displayName = parts[2] + val channel = parts[3] + onUserClick(userId, userName, displayName, channel, 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/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt index 3ad1e8179..f75e75682 100644 --- 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 @@ -221,8 +221,26 @@ private fun WhisperMessageText( launchCustomTab(context, annotation.item) } }, - onTextLongClick = { - onMessageLongClick(message.id, message.fullMessage) + onTextLongClick = { offset -> + val userAnnotation = if (offset >= 0) { + annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + } else { + null + } + + when { + userAnnotation != null -> { + val parts = userAnnotation.item.split("|") + if (parts.size == 3) { + val userId = parts[0].takeIf { it.isNotEmpty() } + val userName = parts[1] + val displayName = parts[2] + onUserClick(userId, userName, displayName, message.badges, true) + } + } + + else -> onMessageLongClick(message.id, message.fullMessage) + } }, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt index 98bdaf920..1cb59c65b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt @@ -4,8 +4,10 @@ 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 @@ -40,7 +42,17 @@ fun BadgeInlineContent( } } - else -> { + 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, 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 index 0b132692d..0e536ee55 100644 --- 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 @@ -38,7 +38,7 @@ fun MessageTextWithInlineContent( onTextClick: (Int) -> Unit, onEmoteClick: (List) -> Unit, modifier: Modifier = Modifier, - onTextLongClick: (() -> Unit)? = null, + onTextLongClick: ((Int) -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { val emoteCoordinator = LocalEmoteAnimationCoordinator.current 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 index c544bab21..b69d89e09 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed @@ -88,6 +89,7 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.ui.main.channel.ChannelTabUiState +import kotlin.math.abs import kotlinx.coroutines.delay import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first @@ -176,7 +178,7 @@ fun FloatingToolbar( LaunchedEffect(isTabsExpanded, selectedIndex) { if (!isTabsExpanded && hasOverflow) { delay(400) // wait for action icons enter animation (350ms tween) - tabListState.animateScrollToItem(selectedIndex) + tabListState.animateScrollToCenter(selectedIndex) } } @@ -256,7 +258,7 @@ fun FloatingToolbar( } }.collect { targetIndex -> if (targetIndex >= 0) { - tabListState.animateScrollToItem(targetIndex) + tabListState.animateScrollToCenter(targetIndex) } } } @@ -547,6 +549,20 @@ private fun Modifier.endAlignedOverflow() = this.then( private const val MAX_LAYOUT_SIZE = 16_777_215 +/** Scrolls the list so that [index] is centered in the viewport. */ +private suspend fun LazyListState.animateScrollToCenter(index: Int) { + animateScrollToItem(index) + val info = layoutInfo + val viewportWidth = info.viewportEndOffset - info.viewportStartOffset + val itemInfo = info.visibleItemsInfo.find { it.index == index } ?: return + val itemCenter = itemInfo.offset + itemInfo.size / 2 + val viewportCenter = viewportWidth / 2 + val delta = (itemCenter - viewportCenter).toFloat() + if (abs(delta) > 1f) { + animateScrollBy(delta) + } +} + /** Measures [LazyRow] at full width (for scrolling) but reports actual content width so the pill wraps content. */ private fun Modifier.wrapLazyRowContent(listState: LazyListState, extraWidth: Int = 0) = layout { measurable, constraints -> val placeable = measurable.measure(constraints) 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 index f81cbad1e..b49400abc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -102,6 +102,7 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.ui.chat.ChatComposable @@ -795,15 +796,24 @@ fun MainScreen( val channel = pagerState.channels[page] ChatComposable( channel = channel, - onUserClick = { userId, userName, displayName, channel, badges, _ -> - dialogViewModel.showUserPopup( - UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - )) + onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> + val shouldOpenPopup = when (inputState.userLongClickBehavior) { + UserLongClickBehavior.MentionsUser -> !isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress + } + if (shouldOpenPopup) { + dialogViewModel.showUserPopup( + UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + ) + } else { + chatInputViewModel.mentionUser(UserName(userName), DisplayName(displayName)) + } }, onMessageLongClick = { messageId, channel, fullMessage -> dialogViewModel.showMessageOptions( @@ -913,7 +923,9 @@ fun MainScreen( onUserClick = dialogViewModel::showUserPopup, onMessageLongClick = dialogViewModel::showMessageOptions, onEmoteClick = dialogViewModel::showEmoteInfo, + userLongClickBehavior = inputState.userLongClickBehavior, onWhisperReply = chatInputViewModel::setWhisperTarget, + onUserMention = chatInputViewModel::mentionUser, bottomContentPadding = effectiveBottomPadding, ) } 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 index 88da55d9f..241aab066 100644 --- 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 @@ -25,6 +25,7 @@ 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.UserLongClickBehavior import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore @@ -255,7 +256,8 @@ class ChatInputViewModel( inputOverlayFlow, helperText, codePointCount, - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget), helperText, codePoints -> + chatSettingsDataStore.userLongClickBehavior, + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget), helperText, codePoints, userLongClickBehavior -> val isMentionsTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 0 val isWhisperTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 1 val isInReplyThread = sheetState is FullScreenSheetState.Replies @@ -310,6 +312,7 @@ class ChatInputViewModel( text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, ), + userLongClickBehavior = userLongClickBehavior, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()).also { _uiState = it } } @@ -541,6 +544,7 @@ data class ChatInputUiState( val whisperTarget: UserName? = null, val isWhisperTabActive: Boolean = false, val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, + val userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, ) @Stable 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 index 913143bd8..71aef37d6 100644 --- 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 @@ -15,6 +15,7 @@ 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.preferences.chat.UserLongClickBehavior import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.ui.chat.BadgeUi import com.flxrs.dankchat.ui.chat.history.MessageHistoryViewModel @@ -34,8 +35,10 @@ fun FullScreenSheetOverlay( onUserClick: (UserPopupStateParams) -> Unit, onMessageLongClick: (MessageOptionsParams) -> Unit, onEmoteClick: (List) -> Unit, + userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, modifier: Modifier = Modifier, onWhisperReply: (UserName) -> Unit = {}, + onUserMention: (UserName, DisplayName) -> Unit = { _, _ -> }, bottomContentPadding: Dp = 0.dp, ) { val isVisible = sheetState !is FullScreenSheetState.Closed @@ -49,7 +52,7 @@ fun FullScreenSheetOverlay( Box( modifier = Modifier.fillMaxSize() ) { - val userClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, _ -> + val popupOnlyClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, _ -> onUserClick( UserPopupStateParams( targetUserId = userId?.let { UserId(it) } ?: UserId(""), @@ -61,6 +64,26 @@ fun FullScreenSheetOverlay( ) } + val mentionableClickHandler: (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( + UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + ) + } else { + onUserMention(UserName(userName), DisplayName(displayName)) + } + } + when (sheetState) { is FullScreenSheetState.Closed -> Unit is FullScreenSheetState.Mention -> { @@ -69,7 +92,7 @@ fun FullScreenSheetOverlay( initialisWhisperTab = false, onDismiss = onDismiss, - onUserClick = userClickHandler, + onUserClick = popupOnlyClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> onMessageLongClick( MessageOptionsParams( @@ -95,7 +118,7 @@ fun FullScreenSheetOverlay( initialisWhisperTab = true, onDismiss = onDismiss, - onUserClick = userClickHandler, + onUserClick = popupOnlyClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> onMessageLongClick( MessageOptionsParams( @@ -120,7 +143,7 @@ fun FullScreenSheetOverlay( rootMessageId = sheetState.replyMessageId, onDismiss = onDismissReplies, - onUserClick = userClickHandler, + onUserClick = mentionableClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> onMessageLongClick( MessageOptionsParams( @@ -143,13 +166,32 @@ fun FullScreenSheetOverlay( key = sheetState.channel.value, parameters = { parametersOf(sheetState.channel) }, ) + val historyClickHandler: (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( + UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge } + ) + ) + } else { + viewModel.insertText("${UserName(userName).valueOrDisplayName(DisplayName(displayName))} ") + } + } MessageHistorySheet( viewModel = viewModel, channel = sheetState.channel, initialFilter = sheetState.initialFilter, onDismiss = onDismiss, - onUserClick = userClickHandler, + onUserClick = historyClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> onMessageLongClick( MessageOptionsParams( From 390f0f731b80ad69d27078cd29739cb0d9e1934e Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 20:21:15 +0100 Subject: [PATCH 108/349] feat(compose): Replace expand/collapse tabs with always-collapsed mode and quick switch menu --- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 371 ++++++++++-------- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 6 +- 2 files changed, 222 insertions(+), 155 deletions(-) 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 index b69d89e09..a6a622475 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -1,8 +1,7 @@ package com.flxrs.dankchat.ui.main +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -12,8 +11,10 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState 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 @@ -25,19 +26,21 @@ 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.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.gestures.animateScrollBy -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -55,10 +58,12 @@ import androidx.compose.material3.TooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableIntStateOf 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.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -78,10 +83,14 @@ import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalConfiguration 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.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize @@ -89,13 +98,12 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.ui.main.channel.ChannelTabUiState +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.launch import kotlin.math.abs -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first -private const val TAB_AUTO_COLLAPSE_DELAY_MS = 1000L - sealed interface ToolbarAction { data class SelectTab(val index: Int) : ToolbarAction data class LongClickTab(val index: Int) : ToolbarAction @@ -141,57 +149,32 @@ fun FloatingToolbar( ) { val density = LocalDensity.current - var isTabsExpanded by remember { mutableStateOf(false) } 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 hasOverflow = totalTabs > 3 val selectedIndex = composePagerState.currentPage - val tabListState = rememberLazyListState() + val tabScrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() - // Expand tabs when pager is swiped in a direction with more channels - LaunchedEffect(composePagerState.isScrollInProgress) { - if (composePagerState.isScrollInProgress && hasOverflow) { - val canScroll = tabListState.canScrollForward || tabListState.canScrollBackward - if (!canScroll) return@LaunchedEffect // all tabs fit, don't expand - val offset = snapshotFlow { composePagerState.currentPageOffsetFraction } - .first { it != 0f } - val current = composePagerState.currentPage - val swipingForward = offset > 0 - val swipingBackward = offset < 0 - if ((swipingForward && current < totalTabs - 1) || (swipingBackward && current > 0)) { - isTabsExpanded = true - } - } - } + val hasOverflow by remember { derivedStateOf { tabScrollState.maxValue > 0 } } - // Auto-collapse after all scrolling stops - LaunchedEffect(isTabsExpanded, composePagerState.isScrollInProgress, tabListState.isScrollInProgress) { - if (isTabsExpanded && !composePagerState.isScrollInProgress && !tabListState.isScrollInProgress) { - delay(TAB_AUTO_COLLAPSE_DELAY_MS) - isTabsExpanded = false - } - } + // 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) } - // Scroll to selected tab after collapse animation settles - LaunchedEffect(isTabsExpanded, selectedIndex) { - if (!isTabsExpanded && hasOverflow) { - delay(400) // wait for action icons enter animation (350ms tween) - tabListState.animateScrollToCenter(selectedIndex) - } - } - - // Reset expanded state when toolbar hides (e.g. keyboard opens in split mode) + // Reset menus when toolbar hides LaunchedEffect(showAppBar) { if (!showAppBar) { - isTabsExpanded = false showOverflowMenu = false + showQuickSwitch = false } } - // Dismiss scrim for inline overflow menu - if (showOverflowMenu) { + // Dismiss scrim for menus + if (showOverflowMenu || showQuickSwitch) { Box( modifier = Modifier .fillMaxSize() @@ -200,6 +183,7 @@ fun FloatingToolbar( interactionSource = remember { MutableInteractionSource() } ) { showOverflowMenu = false + showQuickSwitch = false overflowInitialMenu = AppBarMenu.Main } ) @@ -242,41 +226,39 @@ fun FloatingToolbar( } Box(modifier = scrimModifier) { - // Auto-scroll whenever the selected tab isn't fully visible - LaunchedEffect(Unit) { - snapshotFlow { - val currentIndex = composePagerState.currentPage - val layoutInfo = tabListState.layoutInfo - val viewportStart = layoutInfo.viewportStartOffset - val viewportEnd = layoutInfo.viewportEndOffset - val itemInfo = layoutInfo.visibleItemsInfo.find { it.index == currentIndex } - when { - itemInfo == null -> currentIndex - itemInfo.offset < viewportStart -> currentIndex - itemInfo.offset + itemInfo.size > viewportEnd -> currentIndex - else -> -1 - } - }.collect { targetIndex -> - if (targetIndex >= 0) { - tabListState.animateScrollToCenter(targetIndex) - } + // 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 visibility (keyed on tabs so the - // derivedStateOf recaptures when mention counts change) + // Mention indicators based on scroll position and tab positions val hasLeftMention by remember(tabState.tabs) { derivedStateOf { - val visibleItems = tabListState.layoutInfo.visibleItemsInfo - val firstVisibleIndex = visibleItems.firstOrNull()?.index ?: 0 - tabState.tabs.take(firstVisibleIndex).any { it.mentionCount > 0 } + 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 visibleItems = tabListState.layoutInfo.visibleItemsInfo - val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: (totalTabs - 1) - tabState.tabs.drop(lastVisibleIndex + 1).any { it.mentionCount > 0 } + 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 + } } } @@ -302,7 +284,7 @@ fun FloatingToolbar( enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), ) { - Box(modifier = if (endAligned) Modifier.fillMaxWidth() else Modifier) { + Column(modifier = if (endAligned) Modifier.fillMaxWidth() else Modifier) { val mentionGradientColor = MaterialTheme.colorScheme.error Surface( shape = MaterialTheme.shapes.extraLarge, @@ -340,47 +322,170 @@ fun FloatingToolbar( } }, ) { - LazyRow( - state = tabListState, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .wrapLazyRowContent(tabListState, extraWidth = with(density) { 24.dp.roundToPx() }) - .padding(horizontal = 12.dp) - .clipToBounds() - ) { - itemsIndexed( - items = tabState.tabs, - key = { _, tab -> tab.channel.value } - ) { index, tab -> - val isSelected = index == selectedIndex - val textColor = when { - isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .combinedClickable( - onClick = { onAction(ToolbarAction.SelectTab(index)) }, - onLongClick = { - onAction(ToolbarAction.LongClickTab(index)) - overflowInitialMenu = AppBarMenu.Main - showOverflowMenu = true + 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 textColor = when { + isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .combinedClickable( + onClick = { onAction(ToolbarAction.SelectTab(index)) }, + onLongClick = { + showQuickSwitch = false + onAction(ToolbarAction.LongClickTab(index)) + overflowInitialMenu = AppBarMenu.Main + showOverflowMenu = true + } + ) + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 12.dp) + .onGloballyPositioned { coords -> + val offsets = tabOffsets.value + val widths = 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 = if (isSelected) FontWeight.Bold else FontWeight.Normal, ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(4.dp)) + Badge() + } + } + } + if (hasOverflow) { + Spacer(Modifier.width(24.dp)) + } + } + + // Quick switch dropdown indicator (overlays end of tabs) + if (hasOverflow) { + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { + showOverflowMenu = false + showQuickSwitch = !showQuickSwitch + } + .drawBehind { + val fadeWidth = 24.dp.toPx() + // Gradient leading into the icon + 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), + ) + // Subtle background behind icon + drawRect(color = pillColor.copy(alpha = 0.6f)) + } .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 12.dp) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center ) { - Text( - text = tab.displayName, - color = textColor, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = stringResource(R.string.manage_channels), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (tab.mentionCount > 0) { - Spacer(Modifier.width(4.dp)) - Badge() + } + } + } + } + + // 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.graphicsLayer { + val scale = 1f - (quickSwitchBackProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - quickSwitchBackProgress + }, + ) { + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + quickSwitchBackProgress = event.progress + } + showQuickSwitch = false + } catch (_: CancellationException) { + quickSwitchBackProgress = 0f + } + } + val maxMenuHeight = (LocalConfiguration.current.screenHeightDp * 0.25f).dp + Column( + modifier = Modifier + .width(IntrinsicSize.Min) + .widthIn(max = 200.dp) + .heightIn(max = maxMenuHeight) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp) + ) { + tabState.tabs.forEachIndexed { index, tab -> + val isSelected = index == selectedIndex + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onAction(ToolbarAction.SelectTab(index)) + showQuickSwitch = false + } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = tab.displayName, + style = MaterialTheme.typography.bodyLarge, + color = when { + isSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(8.dp)) + Badge() + } } } } @@ -389,19 +494,8 @@ fun FloatingToolbar( } } - // Action icons + inline overflow menu (animated with expand/collapse) - AnimatedVisibility( - visible = !isTabsExpanded, - enter = expandHorizontally( - expandFrom = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeIn(tween(200)), - exit = shrinkHorizontally( - shrinkTowards = Alignment.Start, - animationSpec = tween(350, easing = FastOutSlowInEasing) - ) + fadeOut(tween(150)) - ) { - Row(verticalAlignment = Alignment.Top) { + // Action icons + inline overflow menu + Row(verticalAlignment = Alignment.Top) { Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.width(IntrinsicSize.Min)) { @@ -477,6 +571,7 @@ fun FloatingToolbar( } } IconButton(onClick = { + showQuickSwitch = false overflowInitialMenu = AppBarMenu.Main showOverflowMenu = !showOverflowMenu }) { @@ -513,7 +608,6 @@ fun FloatingToolbar( } } } - } } } } @@ -549,32 +643,3 @@ private fun Modifier.endAlignedOverflow() = this.then( private const val MAX_LAYOUT_SIZE = 16_777_215 -/** Scrolls the list so that [index] is centered in the viewport. */ -private suspend fun LazyListState.animateScrollToCenter(index: Int) { - animateScrollToItem(index) - val info = layoutInfo - val viewportWidth = info.viewportEndOffset - info.viewportStartOffset - val itemInfo = info.visibleItemsInfo.find { it.index == index } ?: return - val itemCenter = itemInfo.offset + itemInfo.size / 2 - val viewportCenter = viewportWidth / 2 - val delta = (itemCenter - viewportCenter).toFloat() - if (abs(delta) > 1f) { - animateScrollBy(delta) - } -} - -/** Measures [LazyRow] at full width (for scrolling) but reports actual content width so the pill wraps content. */ -private fun Modifier.wrapLazyRowContent(listState: LazyListState, extraWidth: Int = 0) = layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - val items = listState.layoutInfo.visibleItemsInfo - val contentWidth = if (items.isNotEmpty()) { - val lastItem = items.last() - lastItem.offset + lastItem.size + extraWidth - } else { - placeable.width - } - val width = contentWidth.coerceAtMost(placeable.width) - layout(width, placeable.height) { - placeable.place(0, 0) - } -} 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 index adcb2ea43..ccb720383 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -82,10 +82,12 @@ fun InlineOverflowMenu( progress.collect { event -> backProgress = event.progress } - backProgress = 0f when (currentMenu) { AppBarMenu.Main -> onDismiss() - else -> currentMenu = AppBarMenu.Main + else -> { + backProgress = 0f + currentMenu = AppBarMenu.Main + } } } catch (_: CancellationException) { backProgress = 0f From 23dfd449874fdbf18f25cf7837fcefc9ec58e3a2 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 20:59:33 +0100 Subject: [PATCH 109/349] fix(compose): Polish quick switch dropdown icon, gradient, and spacing --- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 index a6a622475..1ee9bb11a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -40,7 +40,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -377,7 +377,7 @@ fun FloatingToolbar( } } if (hasOverflow) { - Spacer(Modifier.width(24.dp)) + Spacer(Modifier.width(18.dp)) } } @@ -390,9 +390,10 @@ fun FloatingToolbar( showOverflowMenu = false showQuickSwitch = !showQuickSwitch } + .defaultMinSize(minHeight = 48.dp) + .padding(start = 4.dp, end = 8.dp) .drawBehind { - val fadeWidth = 24.dp.toPx() - // Gradient leading into the icon + val fadeWidth = 12.dp.toPx() drawRect( brush = Brush.horizontalGradient( colors = listOf(pillColor.copy(alpha = 0f), pillColor.copy(alpha = 0.6f)), @@ -401,17 +402,14 @@ fun FloatingToolbar( size = Size(fadeWidth, size.height), topLeft = Offset(-fadeWidth, 0f), ) - // Subtle background behind icon drawRect(color = pillColor.copy(alpha = 0.6f)) - } - .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 8.dp), + }, contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Default.UnfoldMore, + imageVector = Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.manage_channels), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } From d5a8ef510770f08364da2e8f68b9d8be1241f60c Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 21:24:02 +0100 Subject: [PATCH 110/349] feat(compose): Add scrollbar to quick switch menu, fix back gesture flash on overflow menu --- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 89 ++++++++++++------- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 2 +- 2 files changed, 60 insertions(+), 31 deletions(-) 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 index 1ee9bb11a..50496658b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -12,11 +12,14 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ScrollState +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.fillMaxHeight +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -95,6 +98,10 @@ 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 @@ -447,45 +454,67 @@ fun FloatingToolbar( quickSwitchBackProgress = 0f } } - val maxMenuHeight = (LocalConfiguration.current.screenHeightDp * 0.25f).dp - Column( + val maxMenuHeight = (LocalConfiguration.current.screenHeightDp * 0.3f).dp + val quickSwitchScrollState = rememberScrollState() + val quickSwitchScrollAreaState = rememberScrollAreaState(quickSwitchScrollState) + ScrollArea( + state = quickSwitchScrollAreaState, modifier = Modifier .width(IntrinsicSize.Min) .widthIn(max = 200.dp) .heightIn(max = maxMenuHeight) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp) ) { - tabState.tabs.forEachIndexed { index, tab -> - val isSelected = index == selectedIndex - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onAction(ToolbarAction.SelectTab(index)) - showQuickSwitch = false + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(quickSwitchScrollState) + .padding(vertical = 8.dp) + ) { + tabState.tabs.forEachIndexed { index, tab -> + val isSelected = index == selectedIndex + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onAction(ToolbarAction.SelectTab(index)) + showQuickSwitch = false + } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = tab.displayName, + style = MaterialTheme.typography.bodyLarge, + color = when { + isSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(8.dp)) + Badge() } - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = tab.displayName, - style = MaterialTheme.typography.bodyLarge, - color = when { - isSelected -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface - }, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (tab.mentionCount > 0) { - Spacer(Modifier.width(8.dp)) - Badge() } } } + 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) + ) + ) + } } } } 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 index ccb720383..7ee74d8af 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -120,7 +120,7 @@ fun InlineOverflowMenu( .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), ) { - when (menu) { + when (menu) { AppBarMenu.Main -> { if (!isLoggedIn) { InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { onAction(ToolbarAction.Login); onDismiss() } From 0b3e6b21eb642b05915e6842bb5f85c665c44db3 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 22:00:08 +0100 Subject: [PATCH 111/349] feat(suggestions): Improve emote ranking, add : trigger, sort users/commands alphabetically --- .../data/repo/emote/EmoteUsageRepository.kt | 8 + .../ui/chat/suggestion/SuggestionProvider.kt | 111 ++++++----- .../suggestion/SuggestionFilteringTest.kt | 176 ++++++++++++++++++ .../SuggestionProviderExtractWordTest.kt | 1 + .../chat/suggestion/SuggestionScoringTest.kt | 70 +++++++ 5 files changed, 317 insertions(+), 49 deletions(-) create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionScoringTest.kt 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..c95e9373b 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 @@ -17,6 +21,10 @@ class EmoteUsageRepository( 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 { 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 index 7e82f9142..2790d8214 100644 --- 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 @@ -4,6 +4,7 @@ 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.EmoteRepository +import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -11,26 +12,15 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single -/** - * Provides suggestion filtering logic for chat input. - * Filters emotes, users, and commands based on input text. - */ @Single class SuggestionProvider( private val emoteRepository: EmoteRepository, private val usersRepository: UsersRepository, private val commandRepository: CommandRepository, private val chatSettingsDataStore: ChatSettingsDataStore, + private val emoteUsageRepository: EmoteUsageRepository, ) { - /** - * Get filtered suggestions for the given input text and channel. - * - * Returns a Flow that emits filtered suggestions whenever: - * - Input text changes - * - Available emotes/users/commands change - * - Preference for emote ordering changes - */ fun getSuggestions( inputText: String, cursorPosition: Int, @@ -40,33 +30,49 @@ class SuggestionProvider( return flowOf(emptyList()) } - // Extract the current word being typed val currentWord = extractCurrentWord(inputText, cursorPosition) - if (currentWord.isBlank() || currentWord.length < MIN_SUGGESTION_CHARS) { + if (currentWord.isBlank()) { return flowOf(emptyList()) } + // ':' trigger: emote-only mode with reduced min chars + val isEmoteTrigger = currentWord.startsWith(':') + val emoteQuery = when { + isEmoteTrigger -> currentWord.removePrefix(":") + else -> currentWord + } + + if (isEmoteTrigger && emoteQuery.isEmpty()) { + return flowOf(emptyList()) + } + if (!isEmoteTrigger && currentWord.length < MIN_SUGGESTION_CHARS) { + return flowOf(emptyList()) + } + + if (isEmoteTrigger) { + return getEmoteSuggestions(channel, emoteQuery).map { it.take(MAX_SUGGESTIONS) } + } + return combine( getEmoteSuggestions(channel, currentWord), getUserSuggestions(channel, currentWord), getCommandSuggestions(channel, currentWord), chatSettingsDataStore.settings.map { it.preferEmoteSuggestions } ) { emotes, users, commands, preferEmotes -> - // Order suggestions based on user preference val orderedSuggestions = when { preferEmotes -> emotes + users + commands else -> users + emotes + commands } - // Limit results to reasonable number orderedSuggestions.take(MAX_SUGGESTIONS) } } private fun getEmoteSuggestions(channel: UserName, constraint: String): Flow> { return emoteRepository.getEmotes(channel).map { emotes -> + val recentIds = emoteUsageRepository.recentEmoteIds.value val suggestions = emotes.suggestions.map { Suggestion.EmoteSuggestion(it) } - filterEmotes(suggestions, constraint) + filterEmotes(suggestions, constraint, recentIds) } } @@ -87,60 +93,67 @@ class SuggestionProvider( } } - /** - * Extract the current word being typed from the full input text. - * Only looks backwards from cursor position — returns the text between the last space before cursor and the cursor. - */ internal fun extractCurrentWord(text: String, cursorPosition: Int): String { val cursorPos = cursorPosition.coerceIn(0, text.length) val separator = ' ' - // Only look backwards from cursor — the word being actively typed var start = cursorPos while (start > 0 && text[start - 1] != separator) start-- return text.substring(start, cursorPos) } - /** - * Filter emote suggestions by constraint. - * Prioritizes exact matches over case-insensitive matches. - */ - private fun filterEmotes( + internal fun scoreEmote(code: String, query: String, isRecentlyUsed: Boolean): Int { + val tier = when { + code == query -> 0 + code.startsWith(query) -> 100 + code.startsWith(query, ignoreCase = true) -> 200 + code.contains(query) -> 300 + code.contains(query, ignoreCase = true) -> 400 + else -> -1 + } + if (tier < 0) return tier + + val lengthPenalty = code.length - query.length + val usageBoost = if (isRecentlyUsed) -50 else 0 + return tier + lengthPenalty + usageBoost + } + + internal fun filterEmotes( suggestions: List, - constraint: String + constraint: String, + recentEmoteIds: Set, ): List { - val exactMatches = suggestions.filter { it.emote.code.contains(constraint) } - val caseInsensitiveMatches = (suggestions - exactMatches.toSet()).filter { - it.emote.code.contains(constraint, ignoreCase = true) - } - return exactMatches + caseInsensitiveMatches + return suggestions + .mapNotNull { suggestion -> + val score = scoreEmote(suggestion.emote.code, constraint, suggestion.emote.id in recentEmoteIds) + if (score < 0) null else suggestion to score + } + .sortedBy { it.second } + .map { it.first } } - /** - * Filter user suggestions by constraint. - * Handles @ prefix for mentions. - */ - private fun filterUsers( + internal fun filterUsers( suggestions: List, constraint: String ): List { - return suggestions.mapNotNull { suggestion -> - when { - constraint.startsWith('@') -> suggestion.copy(withLeadingAt = true) - else -> suggestion - }.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } - } + return suggestions + .mapNotNull { suggestion -> + when { + constraint.startsWith('@') -> suggestion.copy(withLeadingAt = true) + else -> suggestion + }.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } + } + .sortedBy { it.name.value.lowercase() } } - /** - * Filter command suggestions by constraint. - */ - private fun filterCommands( + internal fun filterCommands( suggestions: List, constraint: String ): List { - return suggestions.filter { it.command.startsWith(constraint, ignoreCase = true) } + return suggestions + .filter { it.command.startsWith(constraint, ignoreCase = true) } + .sortedBy { it.command.lowercase() } } companion object { 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..ad1d97dac --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt @@ -0,0 +1,176 @@ +package com.flxrs.dankchat.ui.chat.suggestion + +import com.flxrs.dankchat.data.DisplayName +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 + +internal class SuggestionFilteringTest { + + private val provider = SuggestionProvider( + emoteRepository = mockk(), + usersRepository = mockk(), + commandRepository = mockk(), + chatSettingsDataStore = mockk(), + emoteUsageRepository = mockk(), + ) + + private fun emote(code: String, id: String = code) = Suggestion.EmoteSuggestion( + GenericEmote(code = code, url = "", lowResUrl = "", id = id, scale = 1, emoteType = EmoteType.GlobalTwitchEmote) + ) + + // region filterEmotes + + @Test + fun `emotes sorted by score - prefix before substring`() { + val suggestions = listOf(emote("wePog"), emote("PogChamp"), emote("Pog")) + val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + + assertEquals( + expected = listOf("Pog", "PogChamp", "wePog"), + actual = result.map { it.emote.code }, + ) + } + + @Test + fun `emotes sorted by score - shorter before longer in same tier`() { + val suggestions = listOf(emote("PogChamp"), emote("PogU"), emote("PogSlide")) + val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + + assertEquals( + expected = listOf("PogU", "PogChamp", "PogSlide"), + actual = result.map { it.emote.code }, + ) + } + + @Test + fun `emotes sorted by score - exact case prefix before case-insensitive prefix`() { + val suggestions = listOf(emote("POGGERS"), emote("PogChamp")) + val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + + assertEquals( + expected = listOf("PogChamp", "POGGERS"), + actual = result.map { it.emote.code }, + ) + } + + @Test + fun `recently used emote boosted within tier`() { + val suggestions = listOf(emote("PogChamp", id = "1"), emote("PogU", id = "2")) + val result = provider.filterEmotes(suggestions, "Pog", setOf("1")) + + // PogChamp (105 - 50 = 55) beats PogU (101) + assertEquals( + expected = listOf("PogChamp", "PogU"), + actual = result.map { it.emote.code }, + ) + } + + @Test + fun `recently used substring does not beat non-used prefix`() { + val suggestions = listOf(emote("wePog", id = "1"), emote("PogChamp", id = "2")) + val result = provider.filterEmotes(suggestions, "Pog", setOf("1")) + + assertEquals( + expected = listOf("PogChamp", "wePog"), + actual = result.map { it.emote.code }, + ) + } + + @Test + fun `non-matching emotes are excluded`() { + val suggestions = listOf(emote("Kappa"), emote("PogChamp"), emote("LUL")) + val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + + assertEquals( + expected = listOf("PogChamp"), + actual = result.map { it.emote.code }, + ) + } + + // endregion + + // region filterUsers + + @Test + fun `users sorted alphabetically`() { + val suggestions = listOf( + Suggestion.UserSuggestion(DisplayName("Zed")), + Suggestion.UserSuggestion(DisplayName("Alice")), + Suggestion.UserSuggestion(DisplayName("Mike")), + ) + val result = provider.filterUsers(suggestions, "") + + assertEquals( + expected = listOf("Alice", "Mike", "Zed"), + actual = result.map { it.name.value }, + ) + } + + @Test + fun `users filtered by prefix and sorted`() { + val suggestions = listOf( + Suggestion.UserSuggestion(DisplayName("Bob")), + Suggestion.UserSuggestion(DisplayName("Anna")), + Suggestion.UserSuggestion(DisplayName("Alex")), + ) + val result = provider.filterUsers(suggestions, "A") + + assertEquals( + expected = listOf("Alex", "Anna"), + actual = result.map { it.name.value }, + ) + } + + @Test + fun `users with at-prefix get leading at`() { + val suggestions = listOf( + Suggestion.UserSuggestion(DisplayName("Bob")), + Suggestion.UserSuggestion(DisplayName("Bea")), + ) + val result = provider.filterUsers(suggestions, "@B") + + assertEquals( + expected = listOf("@Bea", "@Bob"), + actual = result.map { it.toString() }, + ) + } + + // endregion + + // region filterCommands + + @Test + fun `commands sorted alphabetically`() { + val suggestions = listOf( + Suggestion.CommandSuggestion("/timeout"), + Suggestion.CommandSuggestion("/ban"), + Suggestion.CommandSuggestion("/mod"), + ) + val result = provider.filterCommands(suggestions, "/") + + assertEquals( + expected = listOf("/ban", "/mod", "/timeout"), + actual = result.map { it.command }, + ) + } + + @Test + fun `commands filtered by prefix`() { + val suggestions = listOf( + Suggestion.CommandSuggestion("/timeout"), + Suggestion.CommandSuggestion("/ban"), + Suggestion.CommandSuggestion("/title"), + ) + val result = provider.filterCommands(suggestions, "/ti") + + assertEquals( + expected = listOf("/timeout", "/title"), + actual = result.map { it.command }, + ) + } + + // 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 index aceb861f7..ab5e731dc 100644 --- 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 @@ -11,6 +11,7 @@ internal class SuggestionProviderExtractWordTest { usersRepository = mockk(), commandRepository = mockk(), chatSettingsDataStore = mockk(), + emoteUsageRepository = mockk(), ) @Test 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..54f608a84 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionScoringTest.kt @@ -0,0 +1,70 @@ +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(), + chatSettingsDataStore = mockk(), + emoteUsageRepository = mockk(), + ) + + @Test + fun `exact full match scores lowest`() { + val score = provider.scoreEmote("Pog", "Pog", isRecentlyUsed = false) + assertEquals(expected = 0, actual = score) + } + + @Test + fun `prefix exact case scores lower than prefix case-insensitive`() { + val prefixExact = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) + val prefixInsensitive = provider.scoreEmote("POGGERS", "Pog", isRecentlyUsed = false) + assertTrue(prefixExact < prefixInsensitive) + } + + @Test + fun `prefix scores lower than substring`() { + val prefix = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) + val substring = provider.scoreEmote("wePog", "Pog", isRecentlyUsed = false) + assertTrue(prefix < substring) + } + + @Test + fun `shorter emote beats longer within same tier`() { + val shorter = provider.scoreEmote("PogU", "Pog", isRecentlyUsed = false) + val longer = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) + assertTrue(shorter < longer) + } + + @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 `recently used substring does not beat non-used prefix`() { + val prefix = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) + val recentSubstring = provider.scoreEmote("wePog", "Pog", isRecentlyUsed = true) + assertTrue(prefix < recentSubstring) + } + + @Test + fun `no match returns negative score`() { + val score = provider.scoreEmote("Kappa", "Pog", isRecentlyUsed = false) + assertTrue(score < 0) + } + + @Test + fun `case insensitive substring scores highest tier`() { + val score = provider.scoreEmote("pepog", "Pog", isRecentlyUsed = false) + assertTrue(score >= 400) + } +} From 0f80771ec9fba23d8eb614380ea169b33ca1cc2f Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 22:01:14 +0100 Subject: [PATCH 112/349] refactor: Replace HashSet() with mutableSetOf() across emote code --- .../com/flxrs/dankchat/data/repo/emote/EmoteRepository.kt | 6 +++--- .../kotlin/com/flxrs/dankchat/data/repo/emote/Emotes.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 9a49e687b..1ff0cf735 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 @@ -168,12 +168,12 @@ class EmoteRepository( removedSpaces = removedSpaces, replyMentionOffset = replyMentionOffset ) - val twitchEmoteCodes = twitchEmotes.mapTo(HashSet(twitchEmotes.size)) { it.code } + val twitchEmoteCodes = twitchEmotes.mapTo(mutableSetOf()) { it.code } val cheermotes = when { message is PrivMessage && message.tags["bits"] != null -> parseCheermotes(appendedSpaceAdjustedMessage, channel) else -> emptyList() } - val cheermoteCodes = cheermotes.mapTo(HashSet(cheermotes.size)) { it.code } + val cheermoteCodes = cheermotes.mapTo(mutableSetOf()) { it.code } val thirdPartyEmotes = parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel) .filterNot { it.code in twitchEmoteCodes || it.code in cheermoteCodes } val emotes = twitchEmotes + thirdPartyEmotes + cheermotes @@ -343,7 +343,7 @@ class EmoteRepository( userId: UserId, onFirstPageLoaded: (() -> Unit)? = null ) = withContext(Dispatchers.Default) { - val seenIds = HashSet() + val seenIds = mutableSetOf() val allEmotes = mutableListOf() var totalCount = 0 var isFirstPage = true 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 d5af72ca5..f33518edd 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 @@ -20,7 +20,7 @@ data class ChannelEmoteState( fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes { // Deduplicate twitch emotes by ID — channel (follower) emotes take precedence - val channelEmoteIds = channel.twitchEmotes.mapTo(HashSet()) { it.id } + val channelEmoteIds = channel.twitchEmotes.mapTo(mutableSetOf()) { it.id } val deduplicatedGlobalTwitchEmotes = global.twitchEmotes.filterNot { it.id in channelEmoteIds } return Emotes( twitchEmotes = deduplicatedGlobalTwitchEmotes + channel.twitchEmotes, From c58a0da10c8584b20f998508f61a67f6dbd31d35 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 22:33:53 +0100 Subject: [PATCH 113/349] feat(suggestions): Add emoji suggestions for : trigger, reset list scroll on query change --- .../data/repo/emote/EmojiRepository.kt | 39 +++++++++++++++ .../dankchat/ui/chat/suggestion/Suggestion.kt | 5 ++ .../ui/chat/suggestion/SuggestionProvider.kt | 42 ++++++++++++++-- .../ui/main/input/SuggestionDropdown.kt | 25 ++++++++++ app/src/main/res/raw/emoji_data.json | 1 + .../suggestion/SuggestionFilteringTest.kt | 45 +++++++++++++++++ .../SuggestionProviderExtractWordTest.kt | 1 + .../chat/suggestion/SuggestionScoringTest.kt | 1 + scripts/update_emoji_data.py | 50 +++++++++++++++++++ 9 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmojiRepository.kt create mode 100644 app/src/main/res/raw/emoji_data.json create mode 100644 scripts/update_emoji_data.py 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..b00e0dc17 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmojiRepository.kt @@ -0,0 +1,39 @@ +package com.flxrs.dankchat.data.repo.emote + +import android.content.Context +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 + +@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/ui/chat/suggestion/Suggestion.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/Suggestion.kt index 5306d60d0..ecad224c6 100644 --- 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 @@ -2,6 +2,7 @@ package com.flxrs.dankchat.ui.chat.suggestion import androidx.annotation.StringRes import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.repo.emote.EmojiData import com.flxrs.dankchat.data.twitch.emote.GenericEmote sealed interface Suggestion { @@ -13,6 +14,10 @@ sealed interface 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 } 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 index 2790d8214..13f63f0de 100644 --- 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 @@ -3,6 +3,8 @@ 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.preferences.chat.ChatSettingsDataStore @@ -19,6 +21,7 @@ class SuggestionProvider( private val commandRepository: CommandRepository, private val chatSettingsDataStore: ChatSettingsDataStore, private val emoteUsageRepository: EmoteUsageRepository, + private val emojiRepository: EmojiRepository, ) { fun getSuggestions( @@ -35,7 +38,7 @@ class SuggestionProvider( return flowOf(emptyList()) } - // ':' trigger: emote-only mode with reduced min chars + // ':' trigger: emote + emoji mode with reduced min chars val isEmoteTrigger = currentWord.startsWith(':') val emoteQuery = when { isEmoteTrigger -> currentWord.removePrefix(":") @@ -50,7 +53,13 @@ class SuggestionProvider( } if (isEmoteTrigger) { - return getEmoteSuggestions(channel, emoteQuery).map { it.take(MAX_SUGGESTIONS) } + val emojiSuggestions = filterEmojis(emojiRepository.emojis.value, emoteQuery) + return getEmoteSuggestionsScored(channel, emoteQuery).map { emotePairs -> + (emotePairs + emojiSuggestions) + .sortedBy { it.second } + .map { it.first } + .take(MAX_SUGGESTIONS) + } } return combine( @@ -76,6 +85,14 @@ class SuggestionProvider( } } + private fun getEmoteSuggestionsScored(channel: UserName, constraint: String): Flow>> { + return emoteRepository.getEmotes(channel).map { emotes -> + val recentIds = emoteUsageRepository.recentEmoteIds.value + val suggestions = emotes.suggestions.map { Suggestion.EmoteSuggestion(it) } + filterEmotesScored(suggestions, constraint, recentIds) + } + } + private fun getUserSuggestions(channel: UserName, constraint: String): Flow> { return usersRepository.getUsersFlow(channel).map { displayNameSet -> val suggestions = displayNameSet.map { Suggestion.UserSuggestion(it) } @@ -124,13 +141,30 @@ class SuggestionProvider( constraint: String, recentEmoteIds: Set, ): List { + return filterEmotesScored(suggestions, constraint, recentEmoteIds).map { it.first as Suggestion.EmoteSuggestion } + } + + private fun filterEmotesScored( + suggestions: List, + constraint: String, + recentEmoteIds: Set, + ): List> { return suggestions .mapNotNull { suggestion -> val score = scoreEmote(suggestion.emote.code, constraint, suggestion.emote.id in recentEmoteIds) - if (score < 0) null else suggestion to score + if (score < 0) null else (suggestion as Suggestion) to score } .sortedBy { it.second } - .map { it.first } + } + + internal fun filterEmojis( + emojis: List, + constraint: String, + ): List> { + return emojis.mapNotNull { emoji -> + val score = scoreEmote(emoji.code, constraint, isRecentlyUsed = false) + if (score < 0) null else (Suggestion.EmojiSuggestion(emoji) as Suggestion) to score + } } internal fun filterUsers( 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 index 94012b04d..edceb610b 100644 --- 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 @@ -17,8 +17,10 @@ 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.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.Android import androidx.compose.material.icons.filled.FilterList @@ -29,10 +31,12 @@ 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 @@ -65,6 +69,11 @@ fun SuggestionDropdown( targetOffsetY = { fullHeight -> fullHeight / 4 } ) + fadeOut() + scaleOut(targetScale = 0.92f) ) { + val listState = rememberLazyListState() + LaunchedEffect(suggestions) { + listState.scrollToItem(0) + } + OutlinedCard( modifier = Modifier .padding(horizontal = 2.dp) @@ -73,6 +82,7 @@ fun SuggestionDropdown( elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() .animateContentSize(), @@ -131,6 +141,21 @@ private fun SuggestionItem( ) } + is Suggestion.EmojiSuggestion -> { + Text( + text = suggestion.emoji.unicode, + fontSize = 24.sp, + modifier = Modifier + .size(32.dp) + .padding(end = 12.dp) + .wrapContentSize() + ) + Text( + text = ":${suggestion.emoji.code}:", + style = MaterialTheme.typography.bodyLarge + ) + } + is Suggestion.CommandSuggestion -> { Icon( imageVector = Icons.Default.Android, 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..7dfb91fc9 --- /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_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":"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":"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":"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":"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":"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":"triangular_flag_on_post","unicode":"🚩"},{"code":"triangular_ruler","unicode":"📐"},{"code":"trident","unicode":"🔱"},{"code":"triumph","unicode":"😤"},{"code":"troll","unicode":"🧌"},{"code":"trolleybus","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/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt index ad1d97dac..a5d962d0e 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -15,6 +16,7 @@ internal class SuggestionFilteringTest { commandRepository = mockk(), chatSettingsDataStore = mockk(), emoteUsageRepository = mockk(), + emojiRepository = mockk(), ) private fun emote(code: String, id: String = code) = Suggestion.EmoteSuggestion( @@ -173,4 +175,47 @@ internal class SuggestionFilteringTest { } // endregion + + // region filterEmojis + + @Test + fun `emojis filtered by shortcode`() { + val emojis = listOf( + EmojiData("smile", "😄"), + EmojiData("wave", "👋"), + EmojiData("smirk", "😏"), + ) + val result = provider.filterEmojis(emojis, "smi") + + assertEquals( + expected = listOf("smile", "smirk"), + actual = result.map { (suggestion, _) -> (suggestion as Suggestion.EmojiSuggestion).emoji.code }, + ) + } + + @Test + fun `emojis use same scoring as emotes`() { + val emojis = listOf( + EmojiData("smirk", "😏"), + EmojiData("smile", "😄"), + ) + val result = provider.filterEmojis(emojis, "smi") + + // "smile" (len 5) scores lower than "smirk" (len 5) — both prefix, same length, alphabetical order from input + // Both have score 100 + 2 = 102, so input order preserved + assertEquals(2, result.size) + } + + @Test + fun `non-matching emojis excluded`() { + val emojis = listOf( + EmojiData("wave", "👋"), + EmojiData("heart", "❤️"), + ) + 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 index ab5e731dc..eaa7dab26 100644 --- 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 @@ -12,6 +12,7 @@ internal class SuggestionProviderExtractWordTest { commandRepository = mockk(), chatSettingsDataStore = mockk(), emoteUsageRepository = mockk(), + emojiRepository = mockk(), ) @Test 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 index 54f608a84..72bae9075 100644 --- 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 @@ -13,6 +13,7 @@ internal class SuggestionScoringTest { commandRepository = mockk(), chatSettingsDataStore = mockk(), emoteUsageRepository = mockk(), + emojiRepository = mockk(), ) @Test diff --git a/scripts/update_emoji_data.py b/scripts/update_emoji_data.py new file mode 100644 index 000000000..a0e9f5d87 --- /dev/null +++ b/scripts/update_emoji_data.py @@ -0,0 +1,50 @@ +#!/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 + +EMOJI_DATA_URL = "https://raw.githubusercontent.com/iamcal/emoji-data/master/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() From 3833ea1fde8616fbc77b06762aef150ac271a9d8 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 23:07:51 +0100 Subject: [PATCH 114/349] refactor(suggestions): Match C2 Smart scoring, optimize filtering, add @Immutable to EmojiData --- .../data/repo/emote/EmojiRepository.kt | 2 + .../ui/chat/suggestion/SuggestionProvider.kt | 127 ++++++++++-------- .../suggestion/SuggestionFilteringTest.kt | 112 ++++++--------- .../chat/suggestion/SuggestionScoringTest.kt | 50 ++++--- 4 files changed, 141 insertions(+), 150 deletions(-) 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 index b00e0dc17..7922daad4 100644 --- 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 @@ -9,10 +9,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import androidx.compose.runtime.Immutable 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) 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 index 13f63f0de..becd47f5c 100644 --- 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 @@ -7,6 +7,7 @@ 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.ChatSettingsDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -53,12 +54,9 @@ class SuggestionProvider( } if (isEmoteTrigger) { - val emojiSuggestions = filterEmojis(emojiRepository.emojis.value, emoteQuery) - return getEmoteSuggestionsScored(channel, emoteQuery).map { emotePairs -> - (emotePairs + emojiSuggestions) - .sortedBy { it.second } - .map { it.first } - .take(MAX_SUGGESTIONS) + val emojiResults = filterEmojis(emojiRepository.emojis.value, emoteQuery) + return getScoredEmoteSuggestions(channel, emoteQuery).map { emoteResults -> + mergeSorted(emoteResults, emojiResults) } } @@ -80,23 +78,20 @@ class SuggestionProvider( private fun getEmoteSuggestions(channel: UserName, constraint: String): Flow> { return emoteRepository.getEmotes(channel).map { emotes -> val recentIds = emoteUsageRepository.recentEmoteIds.value - val suggestions = emotes.suggestions.map { Suggestion.EmoteSuggestion(it) } - filterEmotes(suggestions, constraint, recentIds) + filterEmotes(emotes.suggestions, constraint, recentIds) } } - private fun getEmoteSuggestionsScored(channel: UserName, constraint: String): Flow>> { + private fun getScoredEmoteSuggestions(channel: UserName, constraint: String): Flow> { return emoteRepository.getEmotes(channel).map { emotes -> val recentIds = emoteUsageRepository.recentEmoteIds.value - val suggestions = emotes.suggestions.map { Suggestion.EmoteSuggestion(it) } - filterEmotesScored(suggestions, constraint, recentIds) + filterEmotesScored(emotes.suggestions, constraint, recentIds) } } private fun getUserSuggestions(channel: UserName, constraint: String): Flow> { return usersRepository.getUsersFlow(channel).map { displayNameSet -> - val suggestions = displayNameSet.map { Suggestion.UserSuggestion(it) } - filterUsers(suggestions, constraint) + filterUsers(displayNameSet, constraint) } } @@ -105,11 +100,27 @@ class SuggestionProvider( commandRepository.getCommandTriggers(channel), commandRepository.getSupibotCommands(channel) ) { triggers, supibotCommands -> - val allCommands = (triggers + supibotCommands).map { Suggestion.CommandSuggestion(it) } - filterCommands(allCommands, constraint) + filterCommands(triggers + supibotCommands, 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 = ' ' @@ -120,78 +131,88 @@ class SuggestionProvider( 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 tier = when { - code == query -> 0 - code.startsWith(query) -> 100 - code.startsWith(query, ignoreCase = true) -> 200 - code.contains(query) -> 300 - code.contains(query, ignoreCase = true) -> 400 - else -> -1 + 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++ } - if (tier < 0) return tier - val lengthPenalty = code.length - query.length + val extraChars = code.length - query.length + val caseCost = if (caseDiffs == 0) -10 else caseDiffs val usageBoost = if (isRecentlyUsed) -50 else 0 - return tier + lengthPenalty + usageBoost + return caseCost + extraChars * 100 + usageBoost } + // Score raw GenericEmotes, only wrap matches internal fun filterEmotes( - suggestions: List, + emotes: List, constraint: String, recentEmoteIds: Set, ): List { - return filterEmotesScored(suggestions, constraint, recentEmoteIds).map { it.first as Suggestion.EmoteSuggestion } + return filterEmotesScored(emotes, constraint, recentEmoteIds).map { it.suggestion as Suggestion.EmoteSuggestion } } private fun filterEmotesScored( - suggestions: List, + emotes: List, constraint: String, recentEmoteIds: Set, - ): List> { - return suggestions - .mapNotNull { suggestion -> - val score = scoreEmote(suggestion.emote.code, constraint, suggestion.emote.id in recentEmoteIds) - if (score < 0) null else (suggestion as Suggestion) to score + ): List { + return 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.second } + .sortedBy { it.score } } + // Score raw EmojiData, only wrap matches internal fun filterEmojis( emojis: List, constraint: String, - ): List> { - return emojis.mapNotNull { emoji -> - val score = scoreEmote(emoji.code, constraint, isRecentlyUsed = false) - if (score < 0) null else (Suggestion.EmojiSuggestion(emoji) as Suggestion) to score - } + ): List { + return 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 internal fun filterUsers( - suggestions: List, - constraint: String + users: Set, + constraint: String, ): List { - return suggestions - .mapNotNull { suggestion -> - when { - constraint.startsWith('@') -> suggestion.copy(withLeadingAt = true) - else -> suggestion - }.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } + val withAt = constraint.startsWith('@') + return users + .mapNotNull { name -> + val suggestion = Suggestion.UserSuggestion(name, withLeadingAt = withAt) + suggestion.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } } - .sortedBy { it.name.value.lowercase() } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.value }) } + // Filter raw command strings, only wrap matches internal fun filterCommands( - suggestions: List, - constraint: String + commands: List, + constraint: String, ): List { - return suggestions - .filter { it.command.startsWith(constraint, ignoreCase = true) } - .sortedBy { it.command.lowercase() } + return 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 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/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt index a5d962d0e..1459b2e1d 100644 --- 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 @@ -19,72 +19,63 @@ internal class SuggestionFilteringTest { emojiRepository = mockk(), ) - private fun emote(code: String, id: String = code) = Suggestion.EmoteSuggestion( + private fun emote(code: String, id: String = code) = GenericEmote(code = code, url = "", lowResUrl = "", id = id, scale = 1, emoteType = EmoteType.GlobalTwitchEmote) - ) // region filterEmotes @Test - fun `emotes sorted by score - prefix before substring`() { - val suggestions = listOf(emote("wePog"), emote("PogChamp"), emote("Pog")) - val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + fun `emotes sorted by score - shorter before longer`() { + val emotes = listOf(emote("PogChamp"), emote("PogU"), emote("Pog")) + val result = provider.filterEmotes(emotes, "Pog", emptySet()) assertEquals( - expected = listOf("Pog", "PogChamp", "wePog"), + expected = listOf("Pog", "PogU", "PogChamp"), actual = result.map { it.emote.code }, ) } @Test - fun `emotes sorted by score - shorter before longer in same tier`() { - val suggestions = listOf(emote("PogChamp"), emote("PogU"), emote("PogSlide")) - val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + fun `emotes sorted by score - exact case beats case mismatch at same length`() { + val emotes = listOf(emote("POGX"), emote("PogX")) + val result = provider.filterEmotes(emotes, "Pog", emptySet()) + // PogX: 1 case diff + 1*100 = 101, POGX: 2 case diffs + 1*100 = 102 assertEquals( - expected = listOf("PogU", "PogChamp", "PogSlide"), + expected = listOf("PogX", "POGX"), actual = result.map { it.emote.code }, ) } @Test - fun `emotes sorted by score - exact case prefix before case-insensitive prefix`() { - val suggestions = listOf(emote("POGGERS"), emote("PogChamp")) - val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + fun `shorter match beats case mismatch longer match`() { + val emotes = listOf(emote("wikked"), emote("Wink")) + val result = provider.filterEmotes(emotes, "wi", emptySet()) + // Wink: 1 case diff + 2*100 = 201, wikked: -10 + 4*100 = 390 assertEquals( - expected = listOf("PogChamp", "POGGERS"), + expected = listOf("Wink", "wikked"), actual = result.map { it.emote.code }, ) } @Test - fun `recently used emote boosted within tier`() { - val suggestions = listOf(emote("PogChamp", id = "1"), emote("PogU", id = "2")) - val result = provider.filterEmotes(suggestions, "Pog", setOf("1")) + fun `recently used emote gets boost`() { + val emotes = listOf(emote("PogChamp", id = "1"), emote("PogU", id = "2")) + val result = provider.filterEmotes(emotes, "Pog", setOf("1")) - // PogChamp (105 - 50 = 55) beats PogU (101) + // PogChamp: -10 + 5*100 - 50 = 440, PogU: -10 + 1*100 = 90 + // PogU still wins due to length dominance assertEquals( - expected = listOf("PogChamp", "PogU"), - actual = result.map { it.emote.code }, - ) - } - - @Test - fun `recently used substring does not beat non-used prefix`() { - val suggestions = listOf(emote("wePog", id = "1"), emote("PogChamp", id = "2")) - val result = provider.filterEmotes(suggestions, "Pog", setOf("1")) - - assertEquals( - expected = listOf("PogChamp", "wePog"), + expected = listOf("PogU", "PogChamp"), actual = result.map { it.emote.code }, ) } @Test fun `non-matching emotes are excluded`() { - val suggestions = listOf(emote("Kappa"), emote("PogChamp"), emote("LUL")) - val result = provider.filterEmotes(suggestions, "Pog", emptySet()) + val emotes = listOf(emote("Kappa"), emote("PogChamp"), emote("LUL")) + val result = provider.filterEmotes(emotes, "Pog", emptySet()) assertEquals( expected = listOf("PogChamp"), @@ -98,12 +89,8 @@ internal class SuggestionFilteringTest { @Test fun `users sorted alphabetically`() { - val suggestions = listOf( - Suggestion.UserSuggestion(DisplayName("Zed")), - Suggestion.UserSuggestion(DisplayName("Alice")), - Suggestion.UserSuggestion(DisplayName("Mike")), - ) - val result = provider.filterUsers(suggestions, "") + val users = setOf(DisplayName("Zed"), DisplayName("Alice"), DisplayName("Mike")) + val result = provider.filterUsers(users, "") assertEquals( expected = listOf("Alice", "Mike", "Zed"), @@ -113,12 +100,8 @@ internal class SuggestionFilteringTest { @Test fun `users filtered by prefix and sorted`() { - val suggestions = listOf( - Suggestion.UserSuggestion(DisplayName("Bob")), - Suggestion.UserSuggestion(DisplayName("Anna")), - Suggestion.UserSuggestion(DisplayName("Alex")), - ) - val result = provider.filterUsers(suggestions, "A") + val users = setOf(DisplayName("Bob"), DisplayName("Anna"), DisplayName("Alex")) + val result = provider.filterUsers(users, "A") assertEquals( expected = listOf("Alex", "Anna"), @@ -128,11 +111,8 @@ internal class SuggestionFilteringTest { @Test fun `users with at-prefix get leading at`() { - val suggestions = listOf( - Suggestion.UserSuggestion(DisplayName("Bob")), - Suggestion.UserSuggestion(DisplayName("Bea")), - ) - val result = provider.filterUsers(suggestions, "@B") + val users = setOf(DisplayName("Bob"), DisplayName("Bea")) + val result = provider.filterUsers(users, "@B") assertEquals( expected = listOf("@Bea", "@Bob"), @@ -146,12 +126,8 @@ internal class SuggestionFilteringTest { @Test fun `commands sorted alphabetically`() { - val suggestions = listOf( - Suggestion.CommandSuggestion("/timeout"), - Suggestion.CommandSuggestion("/ban"), - Suggestion.CommandSuggestion("/mod"), - ) - val result = provider.filterCommands(suggestions, "/") + val commands = listOf("/timeout", "/ban", "/mod") + val result = provider.filterCommands(commands, "/") assertEquals( expected = listOf("/ban", "/mod", "/timeout"), @@ -161,12 +137,8 @@ internal class SuggestionFilteringTest { @Test fun `commands filtered by prefix`() { - val suggestions = listOf( - Suggestion.CommandSuggestion("/timeout"), - Suggestion.CommandSuggestion("/ban"), - Suggestion.CommandSuggestion("/title"), - ) - val result = provider.filterCommands(suggestions, "/ti") + val commands = listOf("/timeout", "/ban", "/title") + val result = provider.filterCommands(commands, "/ti") assertEquals( expected = listOf("/timeout", "/title"), @@ -181,36 +153,34 @@ internal class SuggestionFilteringTest { @Test fun `emojis filtered by shortcode`() { val emojis = listOf( - EmojiData("smile", "😄"), - EmojiData("wave", "👋"), - EmojiData("smirk", "😏"), + 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 { (suggestion, _) -> (suggestion as Suggestion.EmojiSuggestion).emoji.code }, + 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", "😏"), - EmojiData("smile", "😄"), + EmojiData("smirk", "\uD83D\uDE0F"), + EmojiData("smile", "\uD83D\uDE04"), ) val result = provider.filterEmojis(emojis, "smi") - // "smile" (len 5) scores lower than "smirk" (len 5) — both prefix, same length, alphabetical order from input - // Both have score 100 + 2 = 102, so input order preserved assertEquals(2, result.size) } @Test fun `non-matching emojis excluded`() { val emojis = listOf( - EmojiData("wave", "👋"), - EmojiData("heart", "❤️"), + EmojiData("wave", "\uD83D\uDC4B"), + EmojiData("heart", "\u2764\uFE0F"), ) val result = provider.filterEmojis(emojis, "smi") 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 index 72bae9075..402ae3fb5 100644 --- 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 @@ -19,28 +19,21 @@ internal class SuggestionScoringTest { @Test fun `exact full match scores lowest`() { val score = provider.scoreEmote("Pog", "Pog", isRecentlyUsed = false) - assertEquals(expected = 0, actual = score) + assertEquals(expected = -10, actual = score) } @Test - fun `prefix exact case scores lower than prefix case-insensitive`() { - val prefixExact = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) - val prefixInsensitive = provider.scoreEmote("POGGERS", "Pog", isRecentlyUsed = false) - assertTrue(prefixExact < prefixInsensitive) + 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 `prefix scores lower than substring`() { - val prefix = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) - val substring = provider.scoreEmote("wePog", "Pog", isRecentlyUsed = false) - assertTrue(prefix < substring) - } - - @Test - fun `shorter emote beats longer within same tier`() { - val shorter = provider.scoreEmote("PogU", "Pog", isRecentlyUsed = false) - val longer = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) - assertTrue(shorter < longer) + 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 @@ -51,21 +44,26 @@ internal class SuggestionScoringTest { } @Test - fun `recently used substring does not beat non-used prefix`() { - val prefix = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) - val recentSubstring = provider.scoreEmote("wePog", "Pog", isRecentlyUsed = true) - assertTrue(prefix < recentSubstring) + fun `no match returns NO_MATCH`() { + val score = provider.scoreEmote("Kappa", "Pog", isRecentlyUsed = false) + assertEquals(SuggestionProvider.NO_MATCH, score) } @Test - fun `no match returns negative score`() { - val score = provider.scoreEmote("Kappa", "Pog", isRecentlyUsed = false) - assertTrue(score < 0) + 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 `case insensitive substring scores highest tier`() { - val score = provider.scoreEmote("pepog", "Pog", isRecentlyUsed = false) - assertTrue(score >= 400) + 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) } } From 862dd07106d018a33100dc7618861a6c905f1678 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 26 Mar 2026 23:36:54 +0100 Subject: [PATCH 115/349] fix(suggestions): Wire up suggestions toggle, remove obsolete preferEmoteSuggestions setting --- .../dankchat/preferences/chat/ChatSettings.kt | 1 - .../preferences/chat/ChatSettingsDataStore.kt | 2 -- .../preferences/chat/ChatSettingsScreen.kt | 8 -------- .../preferences/chat/ChatSettingsViewModel.kt | 4 ---- .../ui/chat/suggestion/SuggestionProvider.kt | 12 ++---------- .../dankchat/ui/main/input/ChatInputViewModel.kt | 15 ++++++++++----- .../ui/chat/suggestion/SuggestionFilteringTest.kt | 1 - .../SuggestionProviderExtractWordTest.kt | 1 - .../ui/chat/suggestion/SuggestionScoringTest.kt | 1 - 9 files changed, 12 insertions(+), 33 deletions(-) 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..4992e2d12 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 @@ -9,7 +9,6 @@ import kotlin.uuid.Uuid @Serializable data class ChatSettings( val suggestions: Boolean = true, - val preferEmoteSuggestions: Boolean = false, val supibotSuggestions: Boolean = false, val customCommands: List = emptyList(), val animateGifs: Boolean = true, 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 46766cb17..31658f099 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 @@ -37,7 +37,6 @@ class ChatSettingsDataStore( 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), @@ -60,7 +59,6 @@ class ChatSettingsDataStore( 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 { 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..7611ade03 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 @@ -128,7 +128,6 @@ private fun ChatSettingsScreen( ) { GeneralCategory( suggestions = settings.suggestions, - preferEmoteSuggestions = settings.preferEmoteSuggestions, supibotSuggestions = settings.supibotSuggestions, animateGifs = settings.animateGifs, scrollbackLength = settings.scrollbackLength, @@ -171,7 +170,6 @@ private fun ChatSettingsScreen( @Composable private fun GeneralCategory( suggestions: Boolean, - preferEmoteSuggestions: Boolean, supibotSuggestions: Boolean, animateGifs: Boolean, scrollbackLength: Int, @@ -193,12 +191,6 @@ private fun GeneralCategory( isChecked = suggestions, onClick = { onInteraction(ChatSettingsInteraction.Suggestions(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, 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 6b77ab0bb..2fb9f43ec 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 @@ -36,7 +36,6 @@ class ChatSettingsViewModel( 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) } @@ -77,7 +76,6 @@ sealed interface 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 @@ -100,7 +98,6 @@ sealed interface ChatSettingsInteraction { @Immutable data class ChatSettingsState( val suggestions: Boolean, - val preferEmoteSuggestions: Boolean, val supibotSuggestions: Boolean, val customCommands: ImmutableList, val animateGifs: Boolean, @@ -123,7 +120,6 @@ data class ChatSettingsState( private fun ChatSettings.toState() = ChatSettingsState( suggestions = suggestions, - preferEmoteSuggestions = preferEmoteSuggestions, supibotSuggestions = supibotSuggestions, customCommands = customCommands.toImmutableList(), animateGifs = animateGifs, 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 index becd47f5c..ad17545ef 100644 --- 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 @@ -8,7 +8,6 @@ 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.ChatSettingsDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf @@ -20,7 +19,6 @@ class SuggestionProvider( private val emoteRepository: EmoteRepository, private val usersRepository: UsersRepository, private val commandRepository: CommandRepository, - private val chatSettingsDataStore: ChatSettingsDataStore, private val emoteUsageRepository: EmoteUsageRepository, private val emojiRepository: EmojiRepository, ) { @@ -64,14 +62,8 @@ class SuggestionProvider( getEmoteSuggestions(channel, currentWord), getUserSuggestions(channel, currentWord), getCommandSuggestions(channel, currentWord), - chatSettingsDataStore.settings.map { it.preferEmoteSuggestions } - ) { emotes, users, commands, preferEmotes -> - val orderedSuggestions = when { - preferEmotes -> emotes + users + commands - else -> users + emotes + commands - } - - orderedSuggestions.take(MAX_SUGGESTIONS) + ) { emotes, users, commands -> + (emotes + users + commands).take(MAX_SUGGESTIONS) } } 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 index 241aab066..6f157b3f3 100644 --- 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 @@ -113,11 +113,16 @@ class ChatInputViewModel( // Get suggestions based on current text, cursor position, and active channel private val suggestions: StateFlow> = combine( debouncedTextAndCursor, - chatChannelProvider.activeChannel - ) { (text, cursorPos), channel -> - Triple(text, cursorPos, channel) - }.flatMapLatest { (text, cursorPos, channel) -> - suggestionProvider.getSuggestions(text, cursorPos, channel) + chatChannelProvider.activeChannel, + chatSettingsDataStore.suggestions, + ) { (text, cursorPos), channel, enabled -> + Triple(text, cursorPos, channel) to enabled + }.flatMapLatest { (triple, enabled) -> + val (text, cursorPos, channel) = triple + when { + enabled -> suggestionProvider.getSuggestions(text, cursorPos, channel) + else -> flowOf(emptyList()) + } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val roomStateDisplayText: StateFlow = combine( 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 index 1459b2e1d..e5ced994e 100644 --- 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 @@ -14,7 +14,6 @@ internal class SuggestionFilteringTest { emoteRepository = mockk(), usersRepository = mockk(), commandRepository = mockk(), - chatSettingsDataStore = mockk(), emoteUsageRepository = mockk(), emojiRepository = mockk(), ) 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 index eaa7dab26..714d6e614 100644 --- 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 @@ -10,7 +10,6 @@ internal class SuggestionProviderExtractWordTest { emoteRepository = mockk(), usersRepository = mockk(), commandRepository = mockk(), - chatSettingsDataStore = mockk(), emoteUsageRepository = mockk(), emojiRepository = mockk(), ) 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 index 402ae3fb5..eeea5027c 100644 --- 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 @@ -11,7 +11,6 @@ internal class SuggestionScoringTest { emoteRepository = mockk(), usersRepository = mockk(), commandRepository = mockk(), - chatSettingsDataStore = mockk(), emoteUsageRepository = mockk(), emojiRepository = mockk(), ) From 6834392810bbf4a49d53fdbe7e600bdc67d28941 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 00:16:26 +0100 Subject: [PATCH 116/349] fix(compose): Add predictive back to input overflow menu, fix toolbar menu height with keyboard, add min width to quick switch --- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 10 ++++++--- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 5 ++++- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 2 ++ .../dankchat/ui/main/input/ChatInputLayout.kt | 21 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) 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 index 50496658b..f6c8c50da 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -89,7 +89,7 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -152,6 +152,8 @@ fun FloatingToolbar( addChannelTooltipState: TooltipState? = null, onAddChannelTooltipDismissed: () -> Unit = {}, onSkipTour: () -> Unit = {}, + isKeyboardVisible: Boolean = false, + keyboardHeightDp: Dp = 0.dp, streamToolbarAlpha: Float = 1f, ) { @@ -454,14 +456,15 @@ fun FloatingToolbar( quickSwitchBackProgress = 0f } } - val maxMenuHeight = (LocalConfiguration.current.screenHeightDp * 0.3f).dp + val screenHeight = with(density) { LocalView.current.height.toDp() } + val maxMenuHeight = screenHeight * 0.3f val quickSwitchScrollState = rememberScrollState() val quickSwitchScrollAreaState = rememberScrollAreaState(quickSwitchScrollState) ScrollArea( state = quickSwitchScrollAreaState, modifier = Modifier .width(IntrinsicSize.Min) - .widthIn(max = 200.dp) + .widthIn(min = 125.dp, max = 200.dp) .heightIn(max = maxMenuHeight) ) { Column( @@ -630,6 +633,7 @@ fun FloatingToolbar( }, initialMenu = overflowInitialMenu, onAction = onAction, + keyboardHeightDp = keyboardHeightDp, ) } } 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 index 7ee74d8af..b3d5a783e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import kotlinx.coroutines.CancellationException @@ -73,6 +74,7 @@ fun InlineOverflowMenu( onDismiss: () -> Unit, initialMenu: AppBarMenu = AppBarMenu.Main, onAction: (ToolbarAction) -> Unit, + keyboardHeightDp: Dp = 0.dp, ) { var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } var backProgress by remember { mutableFloatStateOf(0f) } @@ -113,10 +115,11 @@ fun InlineOverflowMenu( ) { menu -> val density = LocalDensity.current val screenHeight = with(density) { LocalView.current.height.toDp() } + val maxHeight = (screenHeight - keyboardHeightDp) * 0.5f Column( modifier = Modifier .width(200.dp) - .heightIn(max = screenHeight * 0.5f) + .heightIn(max = maxHeight) .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), ) { 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 index b49400abc..f056b4b65 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -721,6 +721,8 @@ fun MainScreen( addChannelTooltipState = if (featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) featureTourViewModel.addChannelTooltipState else null, onAddChannelTooltipDismissed = featureTourViewModel::onToolbarHintDismissed, onSkipTour = featureTourViewModel::skipTour, + isKeyboardVisible = isKeyboardVisible, + keyboardHeightDp = with(density) { currentImeHeight.toDp() }, streamToolbarAlpha = streamState.effectiveAlpha, modifier = toolbarModifier, ) 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 index 4e9cfe83f..842ec1d0b 100644 --- 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 @@ -1,5 +1,6 @@ package com.flxrs.dankchat.ui.main.input +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.expandHorizontally @@ -70,6 +71,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -77,6 +79,7 @@ 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.layout.layout import androidx.compose.ui.res.pluralStringResource @@ -93,6 +96,7 @@ import com.flxrs.dankchat.preferences.appearance.InputAction import com.flxrs.dankchat.ui.main.InputState import com.flxrs.dankchat.ui.main.QuickActionsMenu import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.collections.immutable.toImmutableList import sh.calvin.reorderable.ReorderableColumn @@ -368,7 +372,24 @@ fun ChatInputLayout( } }, ) { + var backProgress by remember { mutableFloatStateOf(0f) } + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onOverflowExpandedChanged(false) + } catch (_: CancellationException) { + backProgress = 0f + } + } QuickActionsMenu( + modifier = Modifier.graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + }, surfaceColor = surfaceColor, visibleActions = visibleActions, enabled = enabled, From e5b1d0d72fb0160e2f20aae57487a35b2aa5a9e5 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 13:34:36 +0100 Subject: [PATCH 117/349] fix(compose): Reduce overflow menu max height for landscape compatibility --- app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b3d5a783e..5d11b3a25 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -115,7 +115,7 @@ fun InlineOverflowMenu( ) { menu -> val density = LocalDensity.current val screenHeight = with(density) { LocalView.current.height.toDp() } - val maxHeight = (screenHeight - keyboardHeightDp) * 0.5f + val maxHeight = (screenHeight - keyboardHeightDp) * 0.4f Column( modifier = Modifier .width(200.dp) From a0a499e93c86cfdade2c3ccc77f48da284a56283 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 14:07:47 +0100 Subject: [PATCH 118/349] feat(compose): Add scrollbar to overflow menu, skip intrinsic height to fix ScrollArea crash --- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 51 ++++++++--- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 91 +++++++++++++------ 2 files changed, 103 insertions(+), 39 deletions(-) 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 index f6c8c50da..9909b9d50 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -504,19 +504,21 @@ fun FloatingToolbar( } } } - 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) + if (quickSwitchScrollState.maxValue > 0) { + 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) + ) ) - ) + } } } } @@ -618,6 +620,7 @@ fun FloatingToolbar( enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), modifier = Modifier + .skipIntrinsicHeight() .padding(top = 4.dp) .endAlignedOverflow(), ) { @@ -672,5 +675,29 @@ private fun Modifier.endAlignedOverflow() = this.then( } ) +/** + * 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/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt index 5d11b3a25..253346352 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -8,16 +8,19 @@ 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.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 @@ -44,6 +47,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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 @@ -59,6 +63,10 @@ import androidx.compose.ui.res.stringResource 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 kotlinx.coroutines.CancellationException @@ -96,33 +104,45 @@ fun InlineOverflowMenu( } } - 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", - modifier = Modifier.graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - }, - ) { menu -> - val density = LocalDensity.current - val screenHeight = with(density) { LocalView.current.height.toDp() } - val maxHeight = (screenHeight - keyboardHeightDp) * 0.4f - Column( - modifier = Modifier - .width(200.dp) - .heightIn(max = maxHeight) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), - ) { + val density = LocalDensity.current + val screenHeight = with(density) { LocalView.current.height.toDp() } + val maxHeight = (screenHeight - keyboardHeightDp) * 0.4f + val scrollState = rememberScrollState() + val scrollAreaState = rememberScrollAreaState(scrollState) + + LaunchedEffect(currentMenu) { + scrollState.scrollTo(0) + } + + ScrollArea( + state = scrollAreaState, + modifier = Modifier + .width(200.dp) + .heightIn(max = maxHeight) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + }, + ) { + 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 -> { if (!isLoggedIn) { @@ -166,6 +186,23 @@ fun InlineOverflowMenu( InlineMenuItem(text = stringResource(R.string.clear_chat), icon = Icons.Default.DeleteSweep) { onAction(ToolbarAction.ClearChat); onDismiss() } } } + } + } + if (scrollState.maxValue > 0) { + 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) + ) + ) + } } } } From e317a733cd39bdaff493c427a7508131725f9595 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 14:14:38 +0100 Subject: [PATCH 119/349] fix(compose): Update active channel eagerly on page change, defer notification clearing to settledPage --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 13 ++++++++++--- .../ui/main/channel/ChannelPagerViewModel.kt | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) 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 index f056b4b65..105486325 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -530,11 +530,18 @@ fun MainScreen( } } - // Update ViewModel when user swipes (use settledPage to avoid clearing - // unread/mention indicators for pages scrolled through during programmatic jumps) + // Eagerly update active channel on page change for snappy UI (room state, stream info) + LaunchedEffect(composePagerState.currentPage) { + if (composePagerState.currentPage != pagerState.currentPage) { + channelPagerViewModel.setActivePage(composePagerState.currentPage) + } + } + + // Clear unread/mention indicators only on settledPage to avoid clearing + // for pages scrolled through during programmatic jumps LaunchedEffect(composePagerState.settledPage) { if (composePagerState.settledPage != pagerState.currentPage) { - channelPagerViewModel.onPageChanged(composePagerState.settledPage) + channelPagerViewModel.clearNotifications(composePagerState.settledPage) } } 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 index 422e68211..92e061c9c 100644 --- 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 @@ -37,12 +37,22 @@ class ChannelPagerViewModel( }.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) { - val channel = channels[page] - chatChannelProvider.setActiveChannel(channel) - chatNotificationRepository.clearUnreadMessage(channel) - chatNotificationRepository.clearMentionCount(channel) + chatNotificationRepository.clearUnreadMessage(channels[page]) + chatNotificationRepository.clearMentionCount(channels[page]) } } From 56263573d5bdbee63b24dda26e090b02d0d82f56 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 16:17:22 +0100 Subject: [PATCH 120/349] feat(replies): Add reply to original message option, fill missing translations for sr/fi/en-AU/en-GB/ca --- .../ui/chat/message/MessageOptionsViewModel.kt | 5 ++++- .../dankchat/ui/main/dialog/MainScreenDialogs.kt | 3 +++ .../dankchat/ui/main/dialog/MessageOptionsDialog.kt | 11 ++++++++++- app/src/main/res/values-be-rBY/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 8 ++++++++ app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de-rDE/strings.xml | 1 + app/src/main/res/values-en-rAU/strings.xml | 8 ++++++++ app/src/main/res/values-en-rGB/strings.xml | 8 ++++++++ app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 8 ++++++++ app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu-rHU/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sr/strings.xml | 8 ++++++++ app/src/main/res/values-tr-rTR/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 24 files changed, 73 insertions(+), 2 deletions(-) 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 index ee3488971..879c0a44b 100644 --- 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 @@ -53,13 +53,15 @@ class MessageOptionsViewModel( else -> { val asPrivMessage = message as? PrivMessage val asWhisperMessage = message as? WhisperMessage - val rootId = asPrivMessage?.thread?.rootId + val thread = asPrivMessage?.thread + val rootId = thread?.rootId val name = asPrivMessage?.name ?: asWhisperMessage?.name ?: return@combine MessageOptionsState.NotFound val replyName = name val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage MessageOptionsState.Found( messageId = message.id, rootThreadId = rootId ?: message.id, + rootThreadName = thread?.name, replyName = replyName, name = name, originalMessage = originalMessage.orEmpty(), @@ -127,6 +129,7 @@ sealed interface MessageOptionsState { data class Found( val messageId: String, val rootThreadId: String, + val rootThreadName: UserName?, val replyName: UserName, val name: UserName, val originalMessage: String, 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 index 309771d61..d30063539 100644 --- 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 @@ -205,6 +205,9 @@ fun MainScreenDialogs( onReply = { chatInputViewModel.setReplying(true, s.messageId, s.replyName) }, + onReplyToOriginal = { + chatInputViewModel.setReplying(true, s.rootThreadId, s.rootThreadName ?: s.replyName) + }, onViewThread = { sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) }, 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 index fa85fcdc9..b85979b12 100644 --- 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 @@ -52,6 +52,7 @@ fun MessageOptionsDialog( canJump: Boolean, hasReplyThread: Boolean, onReply: () -> Unit, + onReplyToOriginal: () -> Unit, onJumpToMessage: () -> Unit, onViewThread: () -> Unit, onCopy: () -> Unit, @@ -87,7 +88,15 @@ fun MessageOptionsDialog( } if (canReply && hasReplyThread) { MessageOptionItem( - icon = Icons.AutoMirrored.Filled.Reply, // Using same icon for thread view + icon = Icons.AutoMirrored.Filled.Reply, + text = stringResource(R.string.message_reply_original), + onClick = { + onReplyToOriginal() + onDismiss() + } + ) + MessageOptionItem( + icon = Icons.AutoMirrored.Filled.Reply, text = stringResource(R.string.message_view_thread), onClick = { onViewThread() diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index d68773381..1c474c2f6 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -402,6 +402,7 @@ Скапіраваць тэкст Скапіраваць увесь тэкст Адказаць на паведамленне + Адказаць на зыходнае паведамленне Паглядзець галіну Скапіраваць ID паведамлення Больш… diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ca647025e..9381cb9a9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -349,7 +349,15 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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: %1$s diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6aabc7421..fb3b8200d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -409,6 +409,7 @@ 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… diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c059d8b47..ea7f733c3 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -402,6 +402,7 @@ 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… diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 0babc6680..edab17b8f 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -281,7 +281,15 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 History: %1$s diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index eea1e2e57..4f1cb59ce 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -282,7 +282,15 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 History: %1$s diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 77fca98bf..cb056a7d1 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -395,6 +395,7 @@ Copy message Copy full message Reply to message + Reply to original message View thread Copy message id More… diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 459950bde..a52f1c2d9 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -406,6 +406,7 @@ 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… diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index a7fd6594c..eed69417e 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -308,7 +308,15 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 Historia: %1$s diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index ddb68efae..e11fa746a 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -402,6 +402,7 @@ 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… diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 86a4aa351..99387b346 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -392,6 +392,7 @@ Ü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… diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 031442931..6a90c29dc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -396,6 +396,7 @@ Copia messaggio Copia il messaggio completo Rispondi al messaggio + Rispondi al messaggio originale Visualizza thread Copia id del messaggio Altro… diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index b0181d03c..e51161662 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -386,6 +386,7 @@ メッセージをコピー メッセージ全体をコピー メッセージに返信 + 元のメッセージに返信 スレッドを表示 メッセージIDをコピー もっとみる… diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index c1e44e5fb..22481baa6 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -409,6 +409,7 @@ 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… diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9c233dc05..6cf750ac6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -397,6 +397,7 @@ Copiar mensagem Copiar mensagem inteira Responder mensagem + Responder à mensagem original Ver tópico Copiar ID da mensagem Mais… diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 56fd2c3e7..e926b31f0 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -397,6 +397,7 @@ Copiar mensagem Copiar a mensagem completa Responde à mensagem + Responder à mensagem original Vê a thread Copia o ID da mensagem Mais… diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 6f7fa6948..8bf7e01a2 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -407,6 +407,7 @@ Копировать сообщение Копировать полное сообщения Ответить на сообщение + Ответить на исходное сообщение Посмотреть ветку Копировать ID сообщения Ещё… diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 2d8dcd421..87a25eb91 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -254,7 +254,15 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Banovati ovog korisnika? Obrisati ovu poruku? Obrisati čet? + Копирај поруку + Копирај целу поруку + Одговори на поруку + Одговори на оригиналну поруку + Прикажи тему + Копирај ID поруке + Још… Иди на поруку + Порука није пронађена Порука више није у историји ћаскања Историја порука Историја: %1$s diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 7c1905937..dc849bf56 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -401,6 +401,7 @@ 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ı… diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 67492d32c..8a310d3eb 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -409,6 +409,7 @@ Скопіювати повідомлення Скопіювати повне повідомлення Відповісти на повідомлення + Відповісти на початкове повідомлення Переглянути тему Скопіювати ідентифікатор повідомлення Більше… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46b575df8..cbe9b963d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -594,6 +594,7 @@ Copy message Copy full message Reply to message + Reply to original message View thread Copy message id More… From debded4d86dfd026b32aba7c234bc06d36a19ee3 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 16:46:30 +0100 Subject: [PATCH 121/349] fix(i18n): Fill missing translations for sr, fi, ca, en-AU, en-GB, zh-Hant-TW, kk-KZ, or-IN --- .../main/res/values-b+zh+Hant+TW/strings.xml | 224 +++++++- app/src/main/res/values-ca/strings.xml | 259 +++++++--- app/src/main/res/values-en-rAU/strings.xml | 172 +++++++ app/src/main/res/values-en-rGB/strings.xml | 171 +++++++ app/src/main/res/values-fi-rFI/strings.xml | 176 ++++++- app/src/main/res/values-kk-rKZ/strings.xml | 302 ++++++++++- app/src/main/res/values-or-rIN/strings.xml | 250 ++++++++- app/src/main/res/values-sr/strings.xml | 477 +++++++++++++----- 8 files changed, 1833 insertions(+), 198 deletions(-) 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 313164c44..e92611341 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,6 +45,9 @@ FeelsDankMan DankChat 背景執行中 開啟表情符號選單 + 關閉表情符號選單 + 沒有最近使用的表情符號 + 表情符號 登入至 Twitch.tv 開始聊天 已斷線 @@ -54,14 +59,54 @@ 已登入為 %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$d 秒 + + + %1$d 分鐘 + + + %1$d 小時 + + + %1$d 天 + + + %1$d 週 + + %1$s %2$s + %1$s %2$s %3$s + 貼上 頻道名稱 + 退格鍵 常用 訂閱 頻道 @@ -82,6 +127,81 @@ %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 +224,12 @@ 確認移除頻道 您確定要將此頻道移除嗎? 您確定要移除頻道\"%1$s\"嗎? + 移除此頻道? + 移除頻道\"%1$s\"? 移除 確認封鎖頻道 您確定要封鎖頻道\"%1$s\"嗎? + 封鎖頻道\"%1$s\"? 封鎖 移除封鎖 提及使用者 @@ -130,6 +253,35 @@ 您可以設定一個自訂的主機來用於上傳媒體,例如imgur.com或是s-ul.eu。DankChat使用與Chatterino相同的設定格式。\n若需幫助請查看這則指導文章: https://wiki.chatterino.com/Image%20Uploader/ 開啟/關閉全螢幕 開啟/關閉實況 + 顯示實況 + 隱藏實況 + 全螢幕 + 退出全螢幕 + 隱藏輸入框 + 頻道設定 + + 最多 %1$d 個動作 + + 搜尋訊息 + 上一則訊息 + 切換實況 + 頻道設定 + 全螢幕 + 隱藏輸入框 + 設定動作 + 僅限表情符號 + 僅限訂閱者 + 低速模式 + 獨特聊天 (R9K) + 僅限追隨者 + 自訂 + 任何人 + %1$d秒 + %1$d分 + %1$d時 + %1$d天 + %1$d週 + %1$d月 帳戶 重新登入 登出 @@ -140,12 +292,15 @@ 聊天室狀態 確認永ban 您確定要永ban這名用戶嗎? + 封鎖此使用者? Ban 確認禁言 禁言 確認訊息刪除 您確定要刪除這則訊息嗎? + 刪除此訊息? 刪除 + 清除聊天室? 更新聊天室模式 限定表情符號 訂閱者限定 @@ -257,6 +412,8 @@ Twitch 服務條款和用戶政策 顯示微項動作 顯示用於切換全螢幕、實況與聊天室模式的微項動作 + 顯示字元計數器 + 在輸入框中顯示字碼數 媒體上傳者 設定上傳者 近期上傳 @@ -321,10 +478,12 @@ 使用者 黑名單的使用者 Twitch + 徽章 復原 物件已移除 已封鎖使用者 %1$s 無法解除封鎖使用者 %1$s + 徽章 無法封鎖使用者 %1$s 您的使用者名稱 訂閱與活動 @@ -337,6 +496,7 @@ 基於某些格式產生通知與醒目訊息提示 當某些使用者發出訊息時產生通知與醒目提示 關閉來自於某些使用者的通知與醒目提示 (例如機器人) + 基於徽章對使用者的訊息產生通知與醒目提示 基於某些格式忽略訊息 忽略來自於某些使用的訊息 管理封鎖的Twitch使用者 @@ -354,10 +514,28 @@ 複製訊息 複製完整訊息 回覆訊息 + 回覆原始訊息 查看回覆串 複製訊息ID 更多… + 跳至訊息 + 訊息已不在聊天紀錄中 + 訊息紀錄 + 歷史紀錄:%1$s + 搜尋訊息… + 依使用者名稱篩選 + 包含連結的訊息 + 包含表情符號的訊息 + 依徽章名稱篩選 + 使用者 + 徽章 回覆至@%1$s + 悄悄話至@%1$s + 傳送悄悄話 + 新悄悄話 + 傳送悄悄話至 + 使用者名稱 + 開始 未找到回覆串 未找到訊息 使用表情符號 @@ -398,5 +576,49 @@ 顯示實況類別 同時也顯示實況類別 開關輸入框 - 歷史紀錄:%1$s + 實況主 + 管理員 + 工作人員 + 模組 + 主要模組 + 已驗證 + VIP + 創始者 + 訂閱者 + 選擇自訂標示色彩 + 預設 + 選擇色彩 + 切換應用程式列 + 錯誤: %s + + + DankChat + 讓我們為您進行設定。 + 開始使用 + 使用 Twitch 登入 + 登入以傳送訊息、使用您的表情符號、接收悄悄話,並解鎖所有功能。 + 使用 Twitch 登入 + 登入成功 + 略過 + 繼續 + 訊息紀錄 + DankChat 可於啟動時透過第三方服務載入歷史訊息。\n為了取得這些訊息,DankChat 會將您開啟的頻道名稱傳送至該服務。\n該服務會暫時儲存您(及其他人)造訪的頻道訊息以提供此功能。\n\n您可以稍後在設定中變更此選項,或透過 https://recent-messages.robotty.de/ 了解更多資訊 + 啟用 + 停用 + 通知 + 當應用程式在背景執行時,DankChat 可以在有人於聊天室中提及您時通知您。 + 允許通知 + 若不允許通知,當應用程式在背景執行時,您將不會收到聊天室中的提及通知。 + 開啟通知設定 + + + 可自訂的快捷動作,快速存取搜尋、實況等功能 + 點此查看更多動作並設定您的動作列 + 您可以在此自訂動作列中顯示的動作 + 在輸入框上向下滑動即可快速隱藏 + 點此恢復輸入框 + 下一步 + 了解 + 略過導覽 + 您可以在此新增更多頻道 diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9381cb9a9..0648822bc 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,15 +37,20 @@ 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 + Resposta Tens noves mencions %1$s t\'acaba de mencionar en #%2$s Has estat mencionat a #%1$s @@ -60,6 +66,7 @@ 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 @@ -121,6 +128,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 @@ -132,9 +143,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 @@ -146,6 +159,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 @@ -177,8 +191,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. @@ -190,6 +207,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Minuts Afegir un commandament Eliminar el commandament + Disparador Commandaments Comandaments personalitzats Reportar @@ -201,7 +219,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 @@ -215,6 +246,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Notificacions Xat General + Quant a Aparença DankChat %1$s creat per @flex3rs i col·laboradors Mostrar entrada @@ -236,11 +268,14 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Suggeriments d\'usuari i emotes Mostrar suggeriments per emotes i usuaris actius al escriure 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 + Mode de depuració Proporciona informació per a qualsevol excepció que hagi estat atrapada Format del temps Activar TTS @@ -286,6 +321,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 @@ -294,7 +330,30 @@ 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 + 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. @@ -306,40 +365,48 @@ 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 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ó? @@ -367,15 +434,126 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Missatges amb emotes Filtra per nom d\'insígnia Usuari - Insígnia 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 + 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 + Xat únic (R9K) + Només seguidors + Personalitzat + Qualsevol + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + + + Afegeix un canal per començar a xatejar + + + Mostra l\'emissió + Amaga l\'emissió + Pantalla completa + Surt de la pantalla completa + Amaga l\'entrada + Configuració del canal + + + Cerca missatges + Últim missatge + Commuta l\'emissió + Configuració del canal + Pantalla completa + Amaga l\'entrada + Configura accions + + 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. @@ -456,56 +634,6 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader \u0020(%1$d vegades) - - Retrocés - Envia un xiuxiueig - Xiuxiuejant a @%1$s - Nou xiuxiueig - Envia xiuxiueig a - Nom d\'usuari - Enviar - - - Només emotes - Només subscriptors - Mode lent - Xat únic (R9K) - Només seguidors - Personalitzat - Qualsevol - %1$ds - %1$dm - %1$dh - %1$dd - %1$dw - %1$dmo - - - Afegeix un canal per començar a xatejar - Cap emote recent - - - Mostra l\'emissió - Amaga l\'emissió - Pantalla completa - Surt de la pantalla completa - Amaga l\'entrada - Configuració del canal - - - Cerca missatges - Últim missatge - Commuta l\'emissió - Configuració del canal - Pantalla completa - Amaga l\'entrada - Configura accions - - Màxim %1$d acció - Màxim %1$d accions - Màxim %1$d accions - - DankChat Configurem-ho tot. @@ -525,4 +653,15 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index edab17b8f..1a71e6939 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -444,4 +444,176 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Continue Get Started Skip + Save + Message id copied + Close the emote menu + Emotes + Reply + 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 + 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 + 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 + 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 4f1cb59ce..9a44d3ba3 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -445,4 +445,175 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Continue Get Started Skip + Save + Message id copied + Close the emote menu + Emotes + Reply + 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 + 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 + 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 + 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index eed69417e..fc7a28a2c 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -37,11 +37,14 @@ 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 @@ -119,6 +122,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ä @@ -134,6 +141,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 @@ -192,6 +200,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Minuuttia Lisää komento Poista komento + Laukaisin Komento Mukautetut komennot Ilmoita @@ -202,7 +211,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 @@ -237,6 +260,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Hymiö ja käyttäjä ehdotukset Näyttää ehdotuksia hymiöille ja aktiivisille käyttäjille kirjoittaessasi 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 @@ -254,6 +279,9 @@ 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 TTS Ruudulliset viivat Erota kukin rivi erillaisella taustan kirkkaudella @@ -267,29 +295,100 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Tavallinen painallus popup-ikkuna ja pitkä painallus maininnat Tavallinen painallus maininnat ja pitkä painallus popup-ikkuna 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ä 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 + 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 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ä @@ -299,6 +398,8 @@ 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? @@ -326,15 +427,61 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Emojeja sisältävät viestit Suodata merkin nimen mukaan Käyttäjä - Merkki 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ä + 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. @@ -481,4 +628,15 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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ä diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index bcdd31282..36f3beb2e 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,6 +45,9 @@ FeelsDankMan DankChat фондық тәртібіде істейді Эмодзилерді мәзір ашу + Эмоция мәзірін жабу + Соңғы эмоциялар жоқ + Эмоциялар Twitch.tv кіру Әңгімеге кірісу Ажыратты @@ -54,15 +59,57 @@ %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$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 +129,82 @@ %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 +222,12 @@ Арнаны жоюды растаңыз Бұл арнаны шынымен жойғыңыз келе ме? \"%1$s\" арнасын шынымен жойғыңыз келе ме? + Бұл арнаны жою керек пе? + \"%1$s\" арнасын жою керек пе? Жою Арна блогын растау \"%1$s\" арнасын шынымен блоктағыңыз келе ме? + \"%1$s\" арнасын бұғаттау керек пе? Блоктау Блоктан шығару Пайдаланушыны атап өту @@ -125,6 +251,36 @@ imgur.com немесе s-ul.eu сияқты мультимедиаларды кері жүктеу үшін реттелетін хост орнатуға болады. DankChat анықтама алу үшін Chatterino сияқты конфигурация пішімін пайдаланады.\nCheck бұл нұсқаулықты анықтама үшін пайдаланады: https://wiki.chatterino.com/Image%20Uploader/ Толық экранды ажырату Ағынды ажырату + Ағынды көрсету + Ағынды жасыру + Толық экран + Толық экраннан шығу + Енгізуді жасыру + Арна параметрлері + + Ең көбі %1$d әрекет + Ең көбі %1$d әрекет + + Хабарларды іздеу + Соңғы хабарлама + Ағынды қосу/өшіру + Арна параметрлері + Толық экран + Енгізуді жасыру + Әрекеттерді баптау + Тек эмоция + Тек жазылушы + Баяу режим + Бірегей чат (R9K) + Тек ізбасар + Реттелмелі + Кез келген + %1$dс + %1$dм + %1$dс + %1$dк + %1$dа + %1$dай Тіркелгі Қайта кіру Шығу @@ -135,12 +291,15 @@ Чат режимдері Тыйым салуды растау Сіз осы пайдаланушыға тыйым салғыңыз келетініне сенімдісіз бе? + Осы пайдаланушыға тыйым салу керек пе? Тыйым салу Уақытты растау Күту уақыты Хабарды жоюды растау Сіз бұл хабарламаны жойғыңыз келетініне сенімдісіз бе? + Бұл хабарламаны жою керек пе? Өшіру + Чатты тазарту керек пе? Чат режимдерін жаңарту Тек қана эмоция Тек жазылушы @@ -175,6 +334,7 @@ Токен бос болуы мүмкін емес Токен жарамсыз Модератор + Бас модератор Болжанған \"%1$s\" Деңгей %1$s Уақыт көрсеткілерін көрсету @@ -250,7 +410,31 @@ Twitch қызмет көрсету шарттары & пайдаланушы саясаты: Чип әрекеттерін көрсету Толық экранды, ағындарды қосуға және чат режимдерін реттеуге арналған чиптерді көрсетеді + Таңбалар санағышын көрсету + Енгізу өрісінде код нүктелерінің санын көрсетеді Медиа жүктеп салушы + Жүктеуді баптау + Соңғы жүктеулер + Пайдаланушыларды елемеу тізімі + Еленбейтін пайдаланушылар/аккаунттар тізімі + Құралдар + Тақырып + Қараңғы тақырып + Жарық тақырып + Тізімде жоқ эмоцияларға рұқсат ету + Мақұлданбаған немесе тізімде жоқ эмоцияларды сүзуді өшіреді + Реттелмелі соңғы хабарлар хосты + Ағын ақпаратын алу + Ашық арналардың ағын ақпаратын мерзімді түрде алады. Кірістірілген ағынды іске қосу үшін қажет. + Чатқа қосылмаған жағдайда енгізуді өшіру + Қайталанатын жіберуді қосу + Жіберу түймесі басылып тұрғанда үздіксіз хабар жіберуді қосады + Кіру мерзімі аяқталды! + Сіздің кіру таңбалауышыңыздың мерзімі аяқталды! Қайта кіріңіз. + Қайта кіру + Кіру таңбалауышын тексеру мүмкін болмады, қосылымыңызды тексеріңіз. + Ағынды қайта жүктеуді болдырмау + Бағдар өзгерістерінен немесе DankChat-ты қайта ашқаннан кейін ағынды қайта жүктеуді тәжірибелік түрде болдырмайды. Жаңартылғаннан кейін өзгерістер журналдарын көрсету Не жаңалық Арнайы кіру @@ -270,7 +454,50 @@ Тікелей трансляциялар Суреттегі сурет режимін қосыңыз Қолданба фондық режимде болғанда, ағындарға ойнатуды жалғастыруға мүмкіндік береді + Медиа жүктеуші параметрлерін қалпына келтіру + Медиа жүктеуші параметрлерін әдепкіге қалпына келтіргіңіз келетініне сенімдісіз бе? + Қалпына келтіру + Соңғы жүктеулерді тазалау + Жүктеу тарихын тазалағыңыз келетініне сенімдісіз бе? Жүктелген файлдарыңыз жойылмайды. + Тазалау + Үлгі + Регистрді ескеру + Ерекшелеулер + Қосулы + Хабарландыру + Хабар ерекшелеулерін өңдеу + Ерекшелеулер мен елемеулер + Пайдаланушы аты + Блоктау + Ауыстыру + Елемеулер + Хабар елемеулерін өңдеу + Хабарлар + Пайдаланушылар + Қара тізімдегі пайдаланушылар + Twitch + Белгілер + Қайтару + Элемент жойылды + %1$s пайдаланушысы блоктан шығарылды + %1$s пайдаланушысын блоктан шығару сәтсіз аяқталды + Белгі + %1$s пайдаланушысын блоктау сәтсіз аяқталды + Сіздің пайдаланушы атыңыз + Жазылымдар мен оқиғалар + Хабарландырулар + Алғашқы хабарлар + Көтерілген хабарлар + Арна ұпайларымен сатып алынған ерекшелеулер Жауаптар + Реттелмелі + Белгілі бір үлгілер негізінде хабарландырулар мен ерекшелеулер жасайды. + Белгілі бір пайдаланушылардың хабарлары үшін хабарландырулар мен ерекшелеулер жасайды. + Белгілі бір пайдаланушылардан (мысалы, боттар) хабарландырулар мен ерекшелеулерді өшіреді. + Белгілер негізінде пайдаланушылардың хабарлары үшін хабарландырулар мен ерекшелеулер жасайды. + Белгілі бір үлгілер негізінде хабарларды елемейді. + Белгілі бір пайдаланушылардың хабарларын елемейді. + Блокталған Twitch пайдаланушыларын басқару. Жеке хабарламалар үшін хабарландырулар жасаңыз Кіру ескірген! Сіздің логиніңіз ескірген және кейбір функцияларға қол жеткізе алмайды. Қайтадан кіріңіз. @@ -285,10 +512,28 @@ Хабарды көшіру Толық хабарды көшіру Хабарға жауап беру + Түпнұсқа хабарға жауап беру Жіпті қарау Хабар идентификатын көшіру Қосымша… + Хабарламаға өту + Хабарлама чат тарихында жоқ + Хабар тарихы + Тарих: %1$s + Хабарларды іздеу… + Пайдаланушы аты бойынша сүзу + Сілтемелері бар хабарлар + Эмоциялары бар хабарлар + Белгі аты бойынша сүзу + Пайдаланушы + Белгі \@%1$s жауап беру + \@%1$s сыбырлау + Сыбыр жіберу + Жаңа сыбыр + Сыбыр жіберу + Пайдаланушы аты + Бастау Жауап беру жібі табылмады Хат табылмады Эмоцияны пайдалану @@ -315,9 +560,64 @@ Үлкейту Үлкейту Артқа + Ортақ чат Жанды вит %1$d қарау құралы үшін %2$s Жанды вит %1$d қарау құралы үшін %2$s - Тарих: %1$s + + %d ай + %d ай + + Ашық бастапқы код лицензиялары + + %2$s санатында %1$d көрермен %3$s уақыт бойы тікелей + %2$s санатында %1$d көрермен %3$s уақыт бойы тікелей + + Ағын санатын көрсету + Ағын санатын да көрсетеді + Енгізуді қосу/өшіру + Хабар таратушы + Әкімші + Қызметкер + Модератор + Бас модератор + Расталған + VIP + Негізін қалаушы + Жазылушы + Реттелмелі ерекшелеу түсін таңдау + Әдепкі + Түс таңдау + Қолданба тақтасын қосу/өшіру + Қате: %s + + DankChat + Бастау үшін баптаймыз. + Бастау + Twitch арқылы кіру + Хабарлар жіберу, эмоцияларды пайдалану, сыбырлар алу және барлық мүмкіндіктерді ашу үшін жүйеге кіріңіз. + Twitch арқылы кіру + Кіру сәтті аяқталды + Өткізіп жіберу + Жалғастыру + Хабар тарихы + DankChat іске қосылған кезде үшінші тарап қызметінен тарихи хабарларды жүктейді.\nХабарларды алу үшін DankChat сол қызметке ашылған арналардың атауларын жібереді.\nҚызмет көрсету үшін сіз (және басқалар) баратын арналар үшін хабарларды уақытша сақтайды.\n\nБұны кейінірек параметрлерден өзгертуге немесе https://recent-messages.robotty.de/ сайтында толығырақ білуге болады + Қосу + Өшіру + Хабарландырулар + DankChat қолданба фондық режимде болғанда біреу сізді чатта атап өткенде хабарландыру жасай алады. + Хабарландыруларға рұқсат ету + Хабарландыруларсыз қолданба фонда болғанда біреу сізді чатта атап өткенін білмейсіз. + Хабарландыру параметрлерін ашу + + Іздеу, ағындар және басқа мүмкіндіктерге жылдам қол жеткізу үшін реттелетін әрекеттер + Қосымша әрекеттер мен әрекеттер тақтасын баптау үшін мұнда басыңыз + Әрекеттер тақтасында қандай әрекеттер көрсетілетінін мұнда реттей аласыз + Енгізуді жылдам жасыру үшін төмен қарай сырғытыңыз + Енгізуді қайтару үшін мұнда басыңыз + Келесі + Түсіндім + Турды өткізу + Мұнда қосымша арналар қосуға болады diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 1825cdc1b..bdacd1f3f 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,6 +45,9 @@ FeelsDankMan ପୃଷ୍ଠଭୂମିରେ ଚାଲୁଥିବା DankChat | ଇମୋଟ୍ ମେନୁ ଖୋଲ | + ଇମୋଟ୍ ମେନୁ ବନ୍ଦ କରନ୍ତୁ + କୌଣସି ସାମ୍ପ୍ରତିକ ଇମୋଟ୍ ନାହିଁ + ଇମୋଟ୍ Twitch.tv କୁ ଲଗ୍ଇନ୍ କରନ୍ତୁ | ଚାଟିଂ ଆରମ୍ଭ କରନ୍ତୁ | ବିଚ୍ଛିନ୍ନ ହୋଇଛି | @@ -53,15 +59,57 @@ ଯେପରି ଲଗ୍ ଇନ୍ କରୁଛି | %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$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 +117,10 @@ ପୁନ-ସଂଯୋଗିତ | ଏହି ଚ୍ୟାନେଲ୍ ବିଦ୍ୟମାନ ନାହିଁ | ବିଚ୍ଛିନ୍ନ ହୋଇଛି | - ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପଲବ୍ଧ ନାହିଁ | (ତ୍ରୁଟି %1$s) - ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପଲବ୍ଧ ନାହିଁ | + ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପಲ ବ୍ଧ ନାହିଁ | (ତ୍ରୁଟି %1$s) + ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପಲ ବ୍ଧ ନାହିଁ | ବାର୍ତ୍ତା ଇତିହାସ ସେବା ପୁନରୁଦ୍ଧାର, ବାର୍ତ୍ତା ଇତିହାସରେ ଫାଟ ଥାଇପାରେ | - ବାର୍ତ୍ତା ଇତିହାସ ଉପଲବ୍ଧ ନାହିଁ କାରଣ ଏହି ଚ୍ୟାନେଲ ସେବାରୁ ବାଦ ଦିଆଯାଇଛି | + ବାର୍ତ୍ତା ଇତିହାସ ଉପಲ ବ୍ଧ ନାହିଁ କାରଣ ଏହି ଚ୍ୟାନେଲ ସେବାରୁ ବାଦ ଦିଆଯାଇଛି | ଆପଣ କ historical ତିହାସିକ ବାର୍ତ୍ତା ଦେଖୁ ନାହାଁନ୍ତି କାରଣ ଆପଣ ସାମ୍ପ୍ରତିକ-ବାର୍ତ୍ତା ଏକୀକରଣରୁ ଚୟନ କରିଛନ୍ତି | FFZ ଇମୋଟସ୍ ଲୋଡ୍ କରିବାରେ ବିଫଳ (ଇ %1$s) BTTV ରେଟ୍ ଲୋଡ୍ କରିବାରେ ବିଫଳ | (ଇ %1$s) @@ -81,6 +129,82 @@ %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 +222,12 @@ ଚ୍ୟାନେଲ ଅପସାରଣକୁ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଏହି ଚ୍ୟାନେଲ ଅପସାରଣ କରିବାକୁ ଚାହୁଁଛନ୍ତି କି? ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଚ୍ୟାନେଲ୍ ଅପସାରଣ କରିବାକୁ ଚାହୁଁଛନ୍ତି | \"%1$s\"? + ଏହି ଚ୍ୟାନେଲ ହଟାଇବେ? + \"%1$s\" ଚ୍ୟାନେଲ ହଟାଇବେ? ବାହାର କରନ୍ତୁ ଚ୍ୟାନେଲ ବ୍ଲକ୍ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଚ୍ୟାନେଲକୁ ଅବରୋଧ କରିବାକୁ ଚାହୁଁଛନ୍ତି | \"%1$s\"? + \"%1$s\" ଚ୍ୟାନେଲ ଅବରୋଧ କରିବେ? ଅବରୋଧ କରନ୍ତୁ | ଅନ୍-ବ୍ଲକ୍ | ବ୍ୟବହାରକାରୀଙ୍କୁ ଉଲ୍ଲେଖ କରନ୍ତୁ | @@ -124,6 +251,36 @@ ମିଡିଆ ଅପଲୋଡ୍ କରିବା ପାଇଁ ଆପଣ ଏକ କଷ୍ଟମ୍ ହୋଷ୍ଟ ସେଟ୍ କରିପାରିବେ, ଯେପରିକି imgur.com କିମ୍ବା s-ul.eu | DankChat ଚାଟେରିନୋ ସହିତ ସମାନ ବିନ୍ୟାସ ଫର୍ମାଟ୍ ବ୍ୟବହାର କରେ |\nସାହାଯ୍ୟ ପାଇଁ ଏହି ଗାଇଡ୍ ଯାଞ୍ଚ କରନ୍ତୁ: https://wiki.chatterino.com/Image%20Uploader/ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଟୋଗଲ୍ କରନ୍ତୁ | ଷ୍ଟ୍ରିମ୍ ଟୋଗଲ୍ କରନ୍ତୁ | + ଷ୍ଟ୍ରିମ୍ ଦେଖାନ୍ତୁ + ଷ୍ଟ୍ରିମ୍ ଲୁଚାନ୍ତୁ + ଫୁଲ୍ ସ୍କ୍ରିନ୍ + ଫୁଲ୍ ସ୍କ୍ରିନ୍ ବାହାରନ୍ତୁ + ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ + ଚ୍ୟାନେଲ ସେଟିଂସ୍ + + ସର୍ବାଧିକ %1$d କ୍ରିୟା + ସର୍ବାଧିକ %1$d କ୍ରିୟା + + ବାର୍ତ୍ତା ଖୋଜନ୍ତୁ + ଶେଷ ବାର୍ତ୍ତା + ଷ୍ଟ୍ରିମ୍ ଟୋଗଲ୍ କରନ୍ତୁ + ଚ୍ୟାନେଲ ସେଟିଂସ୍ + ଫୁଲ୍ ସ୍କ୍ରିନ୍ + ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ + କ୍ରିୟାଗୁଡିକ ବିନ୍ୟାସ କରନ୍ତୁ + କେବଳ ଇମୋଟ୍ + କେବଳ ଗ୍ରାହକ + ସ୍ଲୋ ମୋଡ୍ + ୟୁନିକ୍ ଚାଟ୍ (R9K) + କେବଳ ଅନୁଗାମୀ + କଷ୍ଟମ + ଯେକୌଣସି + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo ଆକାଉଣ୍ଟ୍ ପୁନର୍ବାର ଲଗ୍ ଇନ୍ କରନ୍ତୁ | ପ୍ରସ୍ଥାନ କର @@ -134,12 +291,15 @@ ଚାଟ୍ ମୋଡ୍ | ନିଷେଧ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଏହି ଉପଭୋକ୍ତାଙ୍କୁ ବାନ୍ଧି ଦେବାକୁ ଚାହୁଁଛନ୍ତି କି? + ଏହି ଉପଭୋକ୍ତାଙ୍କୁ ନିଷେଧ କରିବେ? ନିଷେଧ | ସମୟ ସମାପ୍ତ ନିଶ୍ଚିତ କରନ୍ତୁ | ସମୟ-ସମାପ୍ତି ବାର୍ତ୍ତା ବିଲୋପକୁ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ ଭାବରେ ଏହି ସନ୍ଦେଶ ବିଲୋପ କରିବାକୁ ଚାହୁଁଛନ୍ତି କି? + ଏହି ବାର୍ତ୍ତା ବିଲୋପ କରିବେ? ବିଲୋପ ହୋଇଛି + ଚାଟ୍ ସଫା କରିବେ? ଚାଟ୍ ମୋଡ୍ ଅପଡେଟ୍ କରନ୍ତୁ Emote କେବଳ କେବଳ ଗ୍ରାହକ @@ -174,6 +334,7 @@ ଟୋକେନ୍ ଖାଲି ହୋଇପାରିବ ନାହିଁ | ଟୋକନ୍ ଅବ alid ଧ ଅଟେ | ମୋଡରେଟର୍ + ମୁଖ୍ୟ ମୋଡରେଟର୍ ପୂର୍ବାନୁମାନ କରାଯାଇଛି | \"%1$s\" ସ୍ତର %1$s ଟାଇମଷ୍ଟ୍ୟାମ୍ପ ଦେଖାନ୍ତୁ | @@ -249,6 +410,8 @@ ସେବା ସର୍ତ୍ତାବଳୀ & ବ୍ୟବହାରକାରୀ ନୀତି: ଚିପ୍ କ୍ରିୟା ଦେଖାନ୍ତୁ | ଫୁଲ୍ ସ୍କ୍ରିନ୍, ଷ୍ଟ୍ରିମ୍ ଏବଂ ଚାଟ୍ ମୋଡ୍ ସଜାଡିବା ପାଇଁ ଚିପ୍ସ ପ୍ରଦର୍ଶନ କରେ | + ଅକ୍ଷର ଗଣକ ଦେଖାନ୍ତୁ + ଇନପୁଟ୍ ଫିଲ୍ଡରେ କୋଡ୍ ପଏଣ୍ଟ ଗଣନା ପ୍ରଦର୍ଶନ କରେ ମିଡିଆ ଅପଲୋଡର୍ | ଅପଲୋଡର୍ କୁ ବିନ୍ୟାସ କରନ୍ତୁ | ସମ୍ପ୍ରତି ଅପଲୋଡ୍ | @@ -256,7 +419,7 @@ ଉପଯୋଗକର୍ତ୍ତା / ଖାତାଗୁଡ଼ିକର ତାଲିକା ଯାହାକୁ ଅଣଦେଖା କରାଯିବ | ସାଧନଗୁଡ଼ିକ | ଥିମ୍ - ଗାଢ଼ ଥିମ୍ + ଗାଢ଼ ଥିମ୍ ହାଲୁକା ଥିମ୍ ତାଲିକାଭୁକ୍ତ ଇମୋଟଗୁଡିକୁ ଅନୁମତି ଦିଅନ୍ତୁ | ଅନୁମୋଦିତ କିମ୍ବା ତାଲିକାଭୁକ୍ତ ଇମୋଟଗୁଡିକର ଫିଲ୍ଟରିଂକୁ ଅକ୍ଷମ କରିଥାଏ | @@ -309,14 +472,16 @@ ପ୍ରତିସ୍ଥାପନ ଅବହେଳା କରନ୍ତୁ | ବାର୍ତ୍ତା ଅଗ୍ରାହ୍ୟ କରନ୍ତୁ | - ଵାର୍ତ୍ତାଗୁଡ଼ିକ + ଵାର୍ତ୍ତାଗୁଡ଼ିକ ଉପଯୋଗକର୍ତ୍ତାଗଣ | କଳା ତାଲିକାଭୁକ୍ତ ବ୍ୟବହାରକାରୀ | Twitch + ବ୍ୟାଜ୍ ପୂର୍ବବତ୍ କରନ୍ତୁ | ଆଇଟମ୍ ଅପସାରିତ ହୋଇଛି | ଅବରୋଧିତ ଉପଭୋକ୍ତା | %1$s ଉପଭୋକ୍ତାଙ୍କୁ ଅବରୋଧ କରିବାରେ ବିଫଳ | %1$s + ବ୍ୟାଜ୍ ଚାଳକକୁ ଅବରୋଧ କରିବାରେ ବିଫଳ | %1$s ଆପଣଙ୍କର ଉପଯୋଗକର୍ତ୍ତା ନାମ ସଦସ୍ୟତା ଏବଂ ଘଟଣା | @@ -328,7 +493,8 @@ କଷ୍ଟମ ନିର୍ଦ୍ଦିଷ୍ଟ s ାଞ୍ଚା ଉପରେ ଆଧାର କରି ବିଜ୍ଞପ୍ତି ସୃଷ୍ଟି କରେ ଏବଂ ବାର୍ତ୍ତାଗୁଡ଼ିକୁ ହାଇଲାଇଟ୍ କରେ | ନିର୍ଦ୍ଦିଷ୍ଟ ବ୍ୟବହାରକାରୀଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ସୃଷ୍ଟି କରେ ଏବଂ ହାଇଲାଇଟ୍ କରେ | - ନିର୍ଦ୍ଦିଷ୍ଟ ଉପଭୋକ୍ତାମାନଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ଏବଂ ହାଇଲାଇଟ୍ ଅକ୍ଷମ କରନ୍ତୁ (ଯଥା ବଟ୍) | + ନିର୍ଦ୍ଦିଷ୍ଟ ଉପଭୋକ୍ତାมାନଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ଏବଂ ହାଇଲାଇଟ୍ ଅକ୍ଷମ କରନ୍ତୁ (ଯଥା ବଟ୍) | + ବ୍ୟାଜ୍ ଆଧାରରେ ଉପଭୋକ୍ତାମାନଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ସୃଷ୍ଟି କରେ ଏବଂ ବାର୍ତ୍ତା ହାଇଲାଇଟ୍ କରେ। ନିର୍ଦ୍ଦିଷ୍ଟ s ାଞ୍ଚା ଉପରେ ଆଧାର କରି ବାର୍ତ୍ତାଗୁଡ଼ିକୁ ଉପେକ୍ଷା କରନ୍ତୁ | ନିର୍ଦ୍ଦିଷ୍ଟ ବ୍ୟବହାରକାରୀଙ୍କ ସନ୍ଦେଶକୁ ଅଣଦେଖା କରନ୍ତୁ | ଅବରୋଧିତ ଟ୍ୱିଚ୍ ବ୍ୟବହାରକାରୀଙ୍କୁ ପରିଚାଳନା କରନ୍ତୁ | @@ -346,10 +512,28 @@ ବାର୍ତ୍ତା କପି କରନ୍ତୁ | ପୂର୍ଣ୍ଣ ବାର୍ତ୍ତା କପି କରନ୍ତୁ | ସନ୍ଦେଶର ଉତ୍ତର ଦିଅ | + ମୂଳ ସନ୍ଦେଶର ଉତ୍ତର ଦିଅନ୍ତୁ ଥ୍ରେଡ୍ ଦେଖନ୍ତୁ | ବାର୍ତ୍ତା ID କପି କରନ୍ତୁ | ଅଧିକ… + ବାର୍ତ୍ତାକୁ ଯାଆନ୍ତୁ + ବାର୍ତ୍ତା ଆଉ ଚାଟ୍ ଇତିହାସରେ ନାହିଁ + ବାର୍ତ୍ତା ଇତିହାସ + ଇତିହାସ: %1$s + ବାର୍ତ୍ତା ଖୋଜନ୍ତୁ… + ଉପଯୋଗକର୍ତ୍ତା ନାମ ଦ୍ୱାରା ଫିଲ୍ଟର କରନ୍ତୁ + ଲିଙ୍କ୍ ଥିବା ବାର୍ତ୍ତା + ଇମୋଟ୍ ଥିବା ବାର୍ତ୍ତା + ବ୍ୟାଜ୍ ନାମ ଦ୍ୱାରା ଫିଲ୍ଟର କରନ୍ତୁ + ଉପଭୋକ୍ତା + ବ୍ୟାଜ୍ ଉତ୍ତର ଦେବା @%1$s + ଫୁସ୍ଫୁସ୍ @%1$s + ଏକ ଫୁସ୍ଫୁସ୍ ପଠାନ୍ତୁ + ନୂଆ ଫୁସ୍ଫୁସ୍ + ଫୁସ୍ଫୁସ୍ ପଠାନ୍ତୁ + ଉପଯୋଗକର୍ତ୍ତା ନାମ + ଆରମ୍ଭ କରନ୍ତୁ ଜରିମାନା ଉତ୍ତର ମିଳିଲା ନାହିଁ | ବାର୍ତ୍ତା ମିଳିଲା ନାହିଁ | ଇମୋଟ୍ ବ୍ୟବହାର କରନ୍ତୁ | @@ -375,6 +559,8 @@ ଲଗଇନ୍ ବାତିଲ୍ କରନ୍ତୁ | ଜୁମ୍ ଆଉଟ୍ କରନ୍ତୁ | ଜୁମ୍ ଇନ୍ କରନ୍ତୁ | + ପଛକୁ + ସେୟାର୍ଡ ଚାଟ୍ ସହିତ ରୁହନ୍ତୁ | %1$d ପାଇଁ ଦର୍ଶକ %2$s ସହିତ ରୁହ | %1$d ପାଇଁ ଦର୍ଶକ | %2$s @@ -383,5 +569,55 @@ %d ମାସ %d ମାସ - ଇତିହାସ: %1$s + ଓପନ୍ ସୋର୍ସ ଲାଇସେନ୍ସ + + %2$s ରେ %1$d ଦର୍ଶକ ସହିତ %3$s ପାଇଁ ଲାଇଭ୍ + %2$s ରେ %1$d ଦର୍ଶକ ସହିତ %3$s ପାଇଁ ଲାଇଭ୍ + + ଷ୍ଟ୍ରିମ୍ ବର୍ଗ ଦେଖାନ୍ତୁ + ଷ୍ଟ୍ରିମ୍ ବର୍ଗ ମଧ୍ୟ ପ୍ରଦର୍ଶନ କରନ୍ତୁ + ଇନପୁଟ୍ ଟୋଗଲ୍ କରନ୍ତୁ + ବ୍ରଡକାଷ୍ଟର୍ + ଆଡମିନ୍ + ଷ୍ଟାଫ୍ + ମୋଡରେଟର୍ + ମୁଖ୍ୟ ମୋଡରେଟର୍ + ଯାଞ୍ଚିତ + VIP + ପ୍ରତିଷ୍ଠାତା + ଗ୍ରାହକ + କଷ୍ଟମ ହାଇଲାଇଟ୍ ରଙ୍ଗ ବାଛନ୍ତୁ + ଡିଫଲ୍ଟ + ରଙ୍ଗ ବାଛନ୍ତୁ + ଆପ୍ ବାର ଟୋଗଲ୍ କରନ୍ତୁ + ତ୍ରୁଟି: %s + + DankChat + ଆସନ୍ତୁ ଆପଣଙ୍କୁ ସେଟ ଅପ୍ କରିବା। + ଆରମ୍ଭ କରନ୍ତୁ + Twitch ସହ ଲଗଇନ୍ କରନ୍ତୁ + ବାର୍ତ୍ତା ପଠାଇବାକୁ, ଆପଣଙ୍କ ଇମୋଟ୍ ବ୍ୟବହାର କରିବାକୁ, ଫୁସ୍ଫୁସ୍ ଗ୍ରହଣ କରିବାକୁ ଏବଂ ସମସ୍ତ ବୈଶିଷ୍ଟ୍ୟ ଅନଲକ୍ କରିବାକୁ ଲଗ ଇନ କରନ୍ତୁ। + Twitch ସହ ଲଗଇନ୍ କରନ୍ତୁ + ଲଗଇନ୍ ସଫଳ + ଛାଡି ଦିଅନ୍ତୁ + ଜାରି ରଖନ୍ତୁ + ବାର୍ତ୍ତା ଇତିହାସ + DankChat ଆରମ୍ଭରେ ତୃତୀୟ-ପକ୍ଷ ସେବାରୁ ଐତିହାସିକ ବାର୍ତ୍ତା ଲୋଡ୍ କରେ।\nବାର୍ତ୍ତା ପାଇବାକୁ, DankChat ଆପଣ ସେହି ସେବାରେ ଖୋଲିଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ନାମ ପଠାଏ।\nସେବା ସାମୟିକ ଭାବରେ ସେବା ପ୍ରଦାନ ପାଇଁ ଆପଣ (ଏବଂ ଅନ୍ୟମାନେ) ପରିଦର୍ଶନ କରୁଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ବାର୍ତ୍ତା ସଂରକ୍ଷଣ କରେ।\n\nଆପଣ ଏହାକୁ ପରବର୍ତ୍ତୀ ସମୟରେ ସେଟିଂସରେ ବଦଳାଇ ପାରିବେ କିମ୍ବା https://recent-messages.robotty.de/ ରେ ଅଧିକ ଜାଣିପାରିବେ + ସକ୍ଷମ କରନ୍ତୁ + ଅକ୍ଷମ କରନ୍ତୁ + ଵିଜ୍ଞପ୍ତି + ଆପ୍ ପୃଷ୍ଠଭୂମିରେ ଥିବାବେଳେ ଚାଟ୍ ରେ କେହି ଆପଣଙ୍କୁ ଉଲ୍ଲେଖ କଲେ DankChat ଆପଣଙ୍କୁ ଜଣାଇପାରେ। + ବିଜ୍ଞପ୍ତି ଅନୁମତି ଦିଅନ୍ତୁ + ବିଜ୍ଞପ୍ତି ବିନା, ଆପ୍ ପୃଷ୍ଠଭୂମିରେ ଥିବାବେଳେ ଚାଟ୍ ରେ କେହି ଆପଣଙ୍କୁ ଉଲ୍ଲେଖ କଲେ ଆପଣ ଜାଣିପାରିବେ ନାହିଁ। + ବିଜ୍ଞପ୍ତି ସେଟିଂସ୍ ଖୋଲନ୍ତୁ + + ଖୋଜ, ଷ୍ଟ୍ରିମ୍ ଏବଂ ଅଧିକ ପାଇଁ ଦ୍ରୁତ ପ୍ରବେଶ ସହ କଷ୍ଟମାଇଜ୍ ଯୋଗ୍ୟ କ୍ରିୟା + ଅଧିକ କ୍ରିୟା ଏବଂ ଆପଣଙ୍କ ଆକ୍ସନ ବାର ବିନ୍ୟାସ କରିବାକୁ ଏଠାରେ ଟ୍ୟାପ୍ କରନ୍ତୁ + ଆପଣ ଏଠାରେ ଆପଣଙ୍କ ଆକ୍ସନ ବାରରେ କେଉଁ କ୍ରିୟା ଦେଖାଯିବ ତାହା କଷ୍ଟମାଇଜ୍ କରିପାରିବେ + ଶୀଘ୍ର ଲୁଚାଇବା ପାଇଁ ଇନପୁଟ୍ ଉପରେ ତଳକୁ ସ୍ୱାଇପ୍ କରନ୍ତୁ + ଇନପୁଟ୍ ଫେରାଇ ଆଣିବାକୁ ଏଠାରେ ଟ୍ୟାପ୍ କରନ୍ତୁ + ପରବର୍ତ୍ତୀ + ବୁଝିଗଲି + ଟୁର୍ ଛାଡି ଦିଅନ୍ତୁ + ଆପଣ ଏଠାରେ ଅଧିକ ଚ୍ୟାନେଲ ଯୋଡିପାରିବେ diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 87a25eb91..e2f8bc3d5 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,15 +38,20 @@ 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 %1$s te je spomenuo u #%2$s Spomenut si u #%1$s @@ -55,6 +67,7 @@ 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 @@ -100,12 +113,107 @@ %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 @@ -113,7 +221,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. @@ -121,12 +229,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 @@ -158,15 +273,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 @@ -180,6 +299,29 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Okidać 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 @@ -200,6 +342,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Pravi tamni mod Forsiraj pozadinu chat-a na crnu Ekran + Компоненте Prikaži obrisane poruke Animirani gifovi Odvojite poruke linijama @@ -212,6 +355,8 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Emotovi i korisnički predlozi Prikaži predloge za emotove i aktivne korisnike dok kucate 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 @@ -228,7 +373,15 @@ 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 + Игнориши емотиконе 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 @@ -240,127 +393,109 @@ 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/sakrij traku aplikacije - Greška: %s - Odjaviti se? - Ukloniti ovaj kanal? - Ukloniti kanal \"%1$s\"? - Blokirati kanal \"%1$s\"? - Banovati ovog korisnika? - Obrisati ovu poruku? - Obrisati čet? - Копирај поруку - Копирај целу поруку - Одговори на поруку - Одговори на оригиналну поруку - Прикажи тему - Копирај ID поруке - Још… - Иди на поруку - Порука није пронађена - Порука више није у историји ћаскања - Историја порука - Историја: %1$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 пута) - + Отпремање медија + Подеси отпремање + Недавна отпремања + Алатке + Тема + Тамна тема + Светла тема + Дозволи неуврштене емотиконе + Искључује филтрирање неодобрених или неуврштених емотикона + Прилагођени хост за недавне поруке + Преузми информације о стриму + Периодично преузима информације о стриму отворених канала. Потребно за покретање уграђеног стрима. + Онемогући унос ако нисте повезани на чат + Омогући поновљено слање + Омогућава непрекидно слање порука док је дугме за слање притиснуто + Пријава је истекла! + Ваш токен за пријаву је истекао! Пријавите се поново. + Пријави се поново + Верификација токена за пријаву није успела, проверите вашу везу. + Спречи поновно учитавање стрима + Омогућава експериментално спречавање поновног учитавања стрима након ротације екрана или поновног отварања DankChat-а. + Прикажи дневник промена после ажурирања + Шта је ново + Прилагођена пријава + Заобиђи Twitch обраду команди + Искључује пресретање Twitch команди и шаље их директно у чат + 7TV ажурирања емотикона уживо + Понашање ажурирања емотикона у позадини + Ажурирања се заустављају после %1$s.\nСмањивање овог броја може продужити трајање батерије. + Ажурирања су увек активна.\nДодавање временског ограничења може продужити трајање батерије. + Ажурирања никада нису активна у позадини. + Никада активно + 1 минут + 5 минута + 30 минута + 1 сат + Увек активно + Стримови уживо + Омогући режим слика-у-слици + Омогућава наставак репродукције стримова док апликација ради у позадини + Обавештења за шапате + Ресетуј подешавања отпремања медија + Да ли сте сигурни да желите да ресетујете подешавања отпремања медија на подразумевана? + Ресетуј + Обриши недавна отпремања + Да ли сте сигурни да желите да обришете историју отпремања? Отпремљени фајлови неће бити обрисани. + Обриши + Образац + Осетљиво на величину слова + Истицања + Укључено + Обавештење + Измени истицања порука + Истицања и игнорисања + Корисничко име + Блокирај + Замена + Игнорисања + Измени игнорисања порука + Поруке + Корисници + Блокирани корисници + Twitch + Значке + Поништи + Ставка уклоњена + Корисник %1$s одблокиран + Одблокирање корисника %1$s није успело + Значка + Блокирање корисника %1$s није успело + Ваше корисничко име + Претплате и догађаји + Обавештења + Прве поруке + Истакнуте поруке + Истицања откупљена поенима канала + Одговори + Прилагођено + Прави обавештења и истиче поруке на основу одређених образаца. + Прави обавештења и истиче поруке од одређених корисника. + Искључи обавештења и истицања од одређених корисника (нпр. ботова). + Прави обавештења и истиче поруке од корисника на основу значки. + Игнориши поруке на основу одређених образаца. + Игнориши поруке од одређених корисника. + Управљај блокираним Twitch корисницима. + Пријава је застарела! + Ваша пријава је застарела и нема приступ неким функцијама. Пријавите се поново. + Прилагођени приказ корисника + Уклони прилагођени приказ корисника + Надимак + Прилагођена боја + Прилагођени надимак + Изабери прилагођену боју корисника + Додај прилагођено име и боју за кориснике + Одговор на + Одговор ка @%1$s + Тема одговора није пронађена Обриши @@ -371,6 +506,77 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Корисничко име Пошаљи + + Користи емотикон + Копирај + Отвори линк емотикона + Слика емотикона + 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 + Оснивач + Претплатник + Изабери прилагођену боју истицања + Подразумевано + Изабери боју + Само емотикони Само претплатници @@ -388,7 +594,6 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/ Додајте канал да бисте почели да ћаскате - Нема недавних емотикона Прикажи стрим @@ -412,6 +617,27 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Максимално %1$d акција + + Копирај поруку + Копирај целу поруку + Одговори на поруку + Одговори на оригиналну поруку + Прикажи тему + Копирај ID поруке + Још… + Иди на поруку + Порука није пронађена + Порука више није у историји ћаскања + Историја порука + Историја: %1$s + Претражи поруке… + Филтрирај по корисничком имену + Поруке са линковима + Поруке са емотима + Филтрирај по називу значке + Корисник + Значка + DankChat Хајде да подесимо све. @@ -431,4 +657,15 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Настави Почни Прескочи + + + Прилагодљиве акције за брз приступ претрази, стримовима и другом + Додирните овде за више акција и подешавање траке акција + Овде можете прилагодити које акције се приказују на траци акција + Превуците надоле по пољу за унос да бисте га брзо сакрили + Додирните овде да вратите поље за унос + Даље + Разумем + Прескочи обилазак + Овде можете додати више канала From 49175935f3f454d78aac64fd3db417a67fb80346 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 17:50:35 +0100 Subject: [PATCH 122/349] feat(automod): Add user-side AutoMod held/accepted/denied messages via EventSub, add user:read:chat scope, filter duplicate NOTICE --- .../dankchat/data/api/auth/AuthApiClient.kt | 1 + .../data/api/eventapi/EventSubClient.kt | 16 ++ .../data/api/eventapi/EventSubManager.kt | 27 +++ .../data/api/eventapi/EventSubMessage.kt | 16 ++ .../data/api/eventapi/EventSubTopic.kt | 43 +++++ .../api/eventapi/dto/EventSubConditionDto.kt | 8 + .../eventapi/dto/EventSubSubscriptionType.kt | 7 + .../notification/AutomodMessageDto.kt | 49 ++++++ .../dankchat/data/repo/chat/ChatConnector.kt | 2 + .../data/repo/chat/ChatEventProcessor.kt | 83 ++++++++- .../data/twitch/message/AutomodMessage.kt | 3 +- .../dankchat/ui/chat/ChatMessageMapper.kt | 3 +- .../dankchat/ui/chat/ChatMessageUiState.kt | 3 +- .../ui/chat/messages/AutomodMessage.kt | 157 ++++++++++-------- .../main/res/values-b+zh+Hant+TW/strings.xml | 3 + app/src/main/res/values-be-rBY/strings.xml | 3 + app/src/main/res/values-ca/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en-rAU/strings.xml | 3 + app/src/main/res/values-en-rGB/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 3 + app/src/main/res/values-fi-rFI/strings.xml | 3 + app/src/main/res/values-fr-rFR/strings.xml | 3 + app/src/main/res/values-hu-rHU/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja-rJP/strings.xml | 3 + app/src/main/res/values-kk-rKZ/strings.xml | 3 + app/src/main/res/values-or-rIN/strings.xml | 3 + app/src/main/res/values-pl-rPL/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt-rPT/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values-sr/strings.xml | 3 + app/src/main/res/values-tr-rTR/strings.xml | 3 + app/src/main/res/values-uk-rUA/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 38 files changed, 412 insertions(+), 78 deletions(-) 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 c8f54b8ea..82fd04aae 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 @@ -65,6 +65,7 @@ class AuthApiClient(private val authApi: AuthApi, private val json: Json) { "user:manage:chat_color", "user:manage:whispers", "user:read:blocked_users", + "user:read:chat", "user:read:emotes", "whispers:edit", "whispers:read", 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 25befb054..c477220eb 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 @@ -9,6 +9,8 @@ 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 @@ -282,6 +284,20 @@ class EventSubClient( 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) } 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 fb0e0c0cf..a7b060574 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 @@ -3,12 +3,15 @@ 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,6 +20,7 @@ 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 authDataStore: AuthDataStore, developerSettingsDataStore: DeveloperSettingsDataStore, @@ -46,6 +50,21 @@ class EventSubManager( } } + 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)) + } + } + } + scope.launch { if (!isEnabled) { return@launch @@ -64,6 +83,12 @@ class EventSubManager( } } + 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 @@ -75,6 +100,8 @@ class EventSubManager( 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 662bf5df5..fdf5269b0 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 @@ -3,6 +3,8 @@ 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 @@ -30,3 +32,17 @@ data class AutomodUpdate( 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 a33d89847..686ba2c95 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 @@ -3,6 +3,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.EventSubMethod +import com.flxrs.dankchat.data.api.eventapi.dto.EventSubBroadcasterUserConditionDto import com.flxrs.dankchat.data.api.eventapi.dto.EventSubModeratorConditionDto import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionRequestDto import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionType @@ -74,6 +75,48 @@ sealed interface EventSubTopic { 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) 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/EventSubSubscriptionType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionType.kt index a5dc37961..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 @@ -13,5 +13,12 @@ enum class EventSubSubscriptionType { @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/notification/AutomodMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/AutomodMessageDto.kt index 90f8637d9..02ca9343a 100644 --- 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 @@ -76,6 +76,55 @@ data class AutomodMessageUpdateDto( 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, 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 index 13f547ee1..e22414a04 100644 --- 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 @@ -116,4 +116,6 @@ class ChatConnector( val connectedAndHasWhisperTopic: Boolean get() = pubSubManager.connectedAndHasWhisperTopic 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 index fe20c6530..1bebcb454 100644 --- 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 @@ -7,6 +7,8 @@ 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 @@ -131,10 +133,12 @@ class ChatEventProcessor( private suspend fun collectEventSubEvents() { chatConnector.eventSubEvents.collect { eventMessage -> when (eventMessage) { - is ModerationAction -> handleEventSubModeration(eventMessage) - is AutomodHeld -> handleAutomodHeld(eventMessage) - is AutomodUpdate -> handleAutomodUpdate(eventMessage) - is SystemMessage -> postSystemMessageAndReconnect(type = SystemMessageType.Custom(eventMessage.message)) + is ModerationAction -> handleEventSubModeration(eventMessage) + is AutomodHeld -> handleAutomodHeld(eventMessage) + is AutomodUpdate -> handleAutomodUpdate(eventMessage) + is UserMessageHeld -> handleUserMessageHeld(eventMessage) + is UserMessageUpdated -> handleUserMessageUpdated(eventMessage) + is SystemMessage -> postSystemMessageAndReconnect(type = SystemMessageType.Custom(eventMessage.message)) } } } @@ -251,6 +255,64 @@ class ChatEventProcessor( 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) @@ -336,9 +398,15 @@ class ChatEventProcessor( } 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 (chatConnector.connectedAndHasModerateTopic(channel)) { + 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 } } @@ -501,5 +569,6 @@ class ChatEventProcessor( companion object { private val TAG = ChatEventProcessor::class.java.simpleName 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/twitch/message/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/AutomodMessage.kt index ed62b5e29..ed6423d11 100644 --- 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 @@ -13,11 +13,12 @@ data class AutomodMessage( val heldMessageId: String, val userName: UserName, val userDisplayName: DisplayName, - val messageText: String, + val messageText: String?, val reason: TextResource, val badges: List = emptyList(), val color: Int = DEFAULT_COLOR, 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/ui/chat/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt index acebbf978..3b7541325 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -314,9 +314,10 @@ class ChatMessageMapper( }.toImmutableList(), userDisplayName = userName.formatWithDisplayName(userDisplayName), rawNameColor = color, - messageText = messageText, + messageText = messageText?.takeIf { it.isNotEmpty() }, reason = reason, status = uiStatus, + isUserSide = isUserSide, ) } 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 index 3f6981998..9808f664b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -182,9 +182,10 @@ sealed interface ChatMessageUiState { val badges: ImmutableList, val userDisplayName: String, val rawNameColor: Int, - val messageText: String, + val messageText: String?, val reason: TextResource, val status: AutomodMessageStatus, + val isUserSide: Boolean = false, ) : ChatMessageUiState { enum class AutomodMessageStatus { Pending, Approved, Denied, Expired } } 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 index 74b77219a..52c30c37e 100644 --- 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 @@ -10,7 +10,6 @@ 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.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -47,13 +46,13 @@ fun AutomodMessageComposable( onDeny: (heldMessageId: String, channel: UserName) -> Unit, modifier: Modifier = Modifier, ) { - val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHigh 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 @@ -63,9 +62,16 @@ fun AutomodMessageComposable( val approvedText = stringResource(R.string.automod_status_approved) val deniedText = stringResource(R.string.automod_status_denied) val expiredText = stringResource(R.string.automod_status_expired) - - // Header line: [badge] "AutoMod: Held a message for reason: {reason}. Allow will post it in chat. Allow Deny" - val headerString = remember(message, textColor, timestampColor, allowColor, denyColor, textSize, headerText, allowText, denyText, approvedText, deniedText, expiredText) { + 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()) { @@ -94,42 +100,53 @@ fun AutomodMessageComposable( append("AutoMod: ") } - // Reason text - withStyle(SpanStyle(color = textColor)) { - append("$headerText ") - } + 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) } + } - // Allow / Deny buttons or status text - when (message.status) { - AutomodMessageStatus.Pending -> { - pushStringAnnotation(tag = ALLOW_TAG, annotation = message.heldMessageId) - withStyle(SpanStyle(color = allowColor, fontWeight = FontWeight.Bold)) { - append(allowText) + // Mod-side: reason text + Allow/Deny buttons or status + else -> { + withStyle(SpanStyle(color = textColor)) { + append("$headerText ") } - pop() - pushStringAnnotation(tag = DENY_TAG, annotation = message.heldMessageId) - withStyle(SpanStyle(color = denyColor, fontWeight = FontWeight.Bold)) { - append(" $denyText") - } - pop() - } + 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.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.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) + AutomodMessageStatus.Expired -> { + withStyle(SpanStyle(color = textColor.copy(alpha = 0.5f), fontWeight = FontWeight.Bold)) { + append(expiredText) + } + } } } } @@ -138,31 +155,33 @@ fun AutomodMessageComposable( // Body line: "timestamp {displayName}: {message}" val bodyString = remember(message, textColor, nameColor, timestampColor, textSize) { - 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) - append(" ") + 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) + append(" ") + } } - } - // Username in bold with user color - withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { - append("${message.userDisplayName}: ") - } + // Username in bold with user color + withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { + append("${message.userDisplayName}: ") + } - // Message text - withStyle(SpanStyle(color = textColor)) { - append(message.messageText) + // Message text + withStyle(SpanStyle(color = textColor)) { + append(text) + } } } } @@ -189,9 +208,10 @@ fun AutomodMessageComposable( } } - val resolvedAlpha = when (message.status) { - AutomodMessageStatus.Pending -> 1f - else -> 0.5f + val resolvedAlpha = when { + message.isUserSide -> 1f + message.status == AutomodMessageStatus.Pending -> 1f + else -> 0.5f } Column( @@ -199,7 +219,6 @@ fun AutomodMessageComposable( .fillMaxWidth() .wrapContentHeight() .alpha(resolvedAlpha) - .drawBehind { drawRect(backgroundColor) } .padding(horizontal = 2.dp, vertical = 2.dp) ) { // Header line with badge inline content @@ -223,12 +242,14 @@ fun AutomodMessageComposable( }, ) - // Body line (no inline content needed) - TextWithMeasuredInlineContent( - text = bodyString, - inlineContentProviders = emptyMap(), - style = TextStyle(fontSize = textSize), - modifier = Modifier.fillMaxWidth(), - ) + // Body line with held message text + bodyString?.let { + TextWithMeasuredInlineContent( + text = it, + inlineContentProviders = emptyMap(), + style = TextStyle(fontSize = textSize), + modifier = Modifier.fillMaxWidth(), + ) + } } } 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 e92611341..4b488ee90 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 @@ -134,6 +134,9 @@ 已允許 已拒絕 已過期 + 嘿!你的訊息正在被版主審核中,尚未發送。 + 版主已接受你的訊息。 + 版主已拒絕你的訊息。 %1$s (等級 %2$d) 符合 %1$d 個封鎖詞彙 %2$s diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 1c474c2f6..1239a2859 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -493,6 +493,9 @@ Ухвалена Адхілена Тэрмін скончыўся + Гэй! Тваё паведамленне правяраецца мадэратарамі і яшчэ не адпраўлена. + Мадэратары прынялі тваё паведамленне. + Мадэратары адхілілі тваё паведамленне. %1$s (узровень %2$d) супадае з %1$d заблакаваным тэрмінам %2$s diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 0648822bc..84af1f991 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -562,6 +562,9 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index fb3b8200d..c449c8d5e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -494,6 +494,9 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index ea7f733c3..125c84dae 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -499,6 +499,9 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 1a71e6939..1f4d79b53 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -316,6 +316,9 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 9a44d3ba3..af7a5250b 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -317,6 +317,9 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index cb056a7d1..54c8af32f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -492,6 +492,9 @@ 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index a52f1c2d9..9136c48a7 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -506,6 +506,9 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index fc7a28a2c..cd94dff51 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -490,6 +490,9 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index e11fa746a..91ebd6e3b 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -490,6 +490,9 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 99387b346..1e8148dfa 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -477,6 +477,9 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6a90c29dc..d8be33d58 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -473,6 +473,9 @@ 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index e51161662..17bb2a45e 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -459,6 +459,9 @@ 承認済み 拒否済み 期限切れ + おっと!あなたのメッセージはモデレーターが確認中で、まだ送信されていません。 + モデレーターがあなたのメッセージを承認しました。 + モデレーターがあなたのメッセージを拒否しました。 %1$s (レベル %2$d) %1$d件のブロックされた用語 %2$s に一致 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 36f3beb2e..a6a44ff03 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -136,6 +136,9 @@ Мақұлданды Қабылданбады Мерзімі өтті + Эй! Сіздің хабарламаңызды модераторлар тексеріп жатыр, ол әлі жіберілген жоқ. + Модераторлар сіздің хабарламаңызды қабылдады. + Модераторлар сіздің хабарламаңызды қабылдамады. %1$s (деңгей %2$d) %1$d бұғатталған терминге сәйкес келеді %2$s diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index bdacd1f3f..e9f70155c 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -136,6 +136,9 @@ ଅନୁମୋଦିତ ପ୍ରତ୍ୟାଖ୍ୟାତ ମିଆଦ ସମାପ୍ତ + ହେ! ଆପଣଙ୍କ ବାର୍ତ୍ତା ମଡ୍‌ମାନଙ୍କ ଦ୍ୱାରା ଯାଞ୍ଚ ହେଉଛି ଏବଂ ଏପର୍ଯ୍ୟନ୍ତ ପଠାଯାଇ ନାହିଁ। + ମଡ୍‌ମାନେ ଆପଣଙ୍କ ବାର୍ତ୍ତା ଗ୍ରହଣ କରିଛନ୍ତି। + ମଡ୍‌ମାନେ ଆପଣଙ୍କ ବାର୍ତ୍ତା ପ୍ରତ୍ୟାଖ୍ୟାନ କରିଛନ୍ତି। %1$s (ସ୍ତର %2$d) %1$d ଅବରୋଧିତ ଶବ୍ଦ ସହ ମେଳ ଖାଉଛି %2$s diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 22481baa6..e061c5b1b 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -512,6 +512,9 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6cf750ac6..8ec109d24 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -485,6 +485,9 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index e926b31f0..12e8fee01 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -475,6 +475,9 @@ 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 8bf7e01a2..c25d8152c 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -498,6 +498,9 @@ Одобрено Отклонено Истекло + Эй! Твоё сообщение проверяется модераторами и ещё не отправлено. + Модераторы приняли твоё сообщение. + Модераторы отклонили твоё сообщение. %1$s (уровень %2$d) совпадает с %1$d заблокированным термином %2$s diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index e2f8bc3d5..128a2ed0e 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -141,6 +141,9 @@ Одобрено Одбијено Истекло + Еј! Твоја порука се проверава од стране модератора и још није послата. + Модератори су прихватили твоју поруку. + Модератори су одбили твоју поруку. %1$s (ниво %2$d) подудара се са %1$d блокираним термином %2$s diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index dc849bf56..e154444c4 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -498,6 +498,9 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 8a310d3eb..b0dd0d502 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -495,6 +495,9 @@ Схвалено Відхилено Термін минув + Гей! Твоє повідомлення перевіряється модераторами і ще не надіслане. + Модератори прийняли твоє повідомлення. + Модератори відхилили твоє повідомлення. %1$s (рівень %2$d) збігається з %1$d заблокованим терміном %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbe9b963d..e77ecd7b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,6 +139,9 @@ 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 From 404b1e28bc104bb90d78f85fcc5db604bd5ea115 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 19:27:49 +0100 Subject: [PATCH 123/349] feat(debug): Add debug analytics bottom sheet with live session, connection, channel, stream, emote, auth, rules, and error sections --- .../seventv/eventapi/SevenTVEventApiClient.kt | 4 + .../dankchat/data/debug/AuthDebugSection.kt | 27 +++++ .../dankchat/data/debug/BuildDebugSection.kt | 23 +++++ .../data/debug/ChannelDebugSection.kt | 43 ++++++++ .../data/debug/ConnectionDebugSection.kt | 61 ++++++++++++ .../flxrs/dankchat/data/debug/DebugSection.kt | 13 +++ .../data/debug/DebugSectionRegistry.kt | 17 ++++ .../dankchat/data/debug/EmoteDebugSection.kt | 62 ++++++++++++ .../dankchat/data/debug/ErrorsDebugSection.kt | 33 +++++++ .../dankchat/data/debug/RulesDebugSection.kt | 38 +++++++ .../data/debug/SessionDebugSection.kt | 51 ++++++++++ .../dankchat/data/debug/StreamDebugSection.kt | 42 ++++++++ .../data/debug/UserStateDebugSection.kt | 27 +++++ .../data/repo/chat/ChatMessageRepository.kt | 3 + .../dankchat/data/repo/stream/StreamData.kt | 8 +- .../data/repo/stream/StreamDataRepository.kt | 11 ++- .../appearance/AppearanceSettings.kt | 2 +- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 2 + .../dankchat/ui/main/MainScreenViewModel.kt | 37 ++++++- .../dankchat/ui/main/QuickActionsMenu.kt | 12 ++- .../ui/main/dialog/MainScreenDialogs.kt | 11 +++ .../dankchat/ui/main/input/ChatBottomBar.kt | 4 + .../dankchat/ui/main/input/ChatInputLayout.kt | 26 ++++- .../dankchat/ui/main/sheet/DebugInfoSheet.kt | 99 +++++++++++++++++++ .../ui/main/sheet/DebugInfoViewModel.kt | 19 ++++ .../ui/main/sheet/SheetNavigationViewModel.kt | 5 + .../utils/compose/BottomSheetNestedScroll.kt | 27 +++++ .../main/res/values-b+zh+Hant+TW/strings.xml | 2 +- app/src/main/res/values-be-rBY/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de-rDE/strings.xml | 2 +- app/src/main/res/values-en-rAU/strings.xml | 2 +- app/src/main/res/values-en-rGB/strings.xml | 2 +- app/src/main/res/values-en/strings.xml | 2 +- app/src/main/res/values-es-rES/strings.xml | 2 +- app/src/main/res/values-fi-rFI/strings.xml | 2 +- app/src/main/res/values-fr-rFR/strings.xml | 2 +- app/src/main/res/values-hu-rHU/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja-rJP/strings.xml | 2 +- app/src/main/res/values-kk-rKZ/strings.xml | 2 +- app/src/main/res/values-or-rIN/strings.xml | 2 +- app/src/main/res/values-pl-rPL/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-pt-rPT/strings.xml | 2 +- app/src/main/res/values-ru-rRU/strings.xml | 2 +- app/src/main/res/values-sr/strings.xml | 2 +- app/src/main/res/values-tr-rTR/strings.xml | 2 +- app/src/main/res/values-uk-rUA/strings.xml | 2 +- app/src/main/res/values/strings.xml | 3 +- 51 files changed, 718 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoViewModel.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt 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..d1d76cd1b 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 @@ -327,6 +327,10 @@ class SevenTVEventApiClient( return 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 private const val RECONNECT_BASE_DELAY = 1_000L 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..228013e1a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt @@ -0,0 +1,27 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.auth.AuthDataStore +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 { + return authDataStore.settings.map { auth -> + DebugSectionSnapshot( + title = baseTitle, + entries = listOf( + DebugEntry("Logged in as", auth.userName ?: "Not logged in"), + DebugEntry("User ID", auth.userId ?: "N/A"), + ) + ) + } + } +} 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..1384c0282 --- /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..3c8f24de9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt @@ -0,0 +1,43 @@ +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 { + return 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.toDisplayText() + 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..45e2bc3a9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt @@ -0,0 +1,61 @@ +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.pubsub.PubSubManager +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 ConnectionDebugSection( + 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, ticker) { state, topics, _ -> + 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.connectedAndHasWhisperTopic -> "Connected (whispers)" + pubSubManager.connected -> "Connected" + else -> "Disconnected" + } + + val sevenTvStatus = sevenTVEventApiClient.status() + val sevenTvText = when { + sevenTvStatus.connected -> "Connected (${sevenTvStatus.subscriptionCount} subs)" + else -> "Disconnected" + } + + DebugSectionSnapshot( + title = baseTitle, + entries = listOf( + 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..890e2e132 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt @@ -0,0 +1,13 @@ +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) 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..58526c162 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt @@ -0,0 +1,17 @@ +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..b985664f2 --- /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 { + return 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..63f5ed36a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt @@ -0,0 +1,33 @@ +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 { + return 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..6d318b6c0 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt @@ -0,0 +1,38 @@ +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 { + return 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..900df91a4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt @@ -0,0 +1,51 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +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, +) : 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("Total messages received", "${chatMessageRepository.sessionMessageCount}"), + 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..84178f734 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt @@ -0,0 +1,42 @@ +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 { + return 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..53ccd9e6a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt @@ -0,0 +1,27 @@ +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 { + return 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/repo/chat/ChatMessageRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageRepository.kt index 933d677c0..54fa6d605 100644 --- 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 @@ -38,6 +38,8 @@ class ChatMessageRepository( private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val messages = ConcurrentHashMap>>() private val _chatLoadingFailures = MutableStateFlow(emptySet()) + private val _sessionMessageCount = java.util.concurrent.atomic.AtomicInteger(0) + val sessionMessageCount: Int get() = _sessionMessageCount.get() private val scrollBackLengthFlow = chatSettingsDataStore.debouncedScrollBack .onEach { length -> @@ -60,6 +62,7 @@ class ChatMessageRepository( (channel?.let { messages[it] } ?: whispers).value.find { it.message.id == messageId }?.message fun addMessages(channel: UserName, items: List) { + _sessionMessageCount.addAndGet(items.size) messages[channel]?.update { current -> current.addAndLimit(items = items, scrollBackLength, messageProcessor::onMessageRemoved) } 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 index 260a00f0e..ac94b5aec 100644 --- 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 @@ -2,4 +2,10 @@ package com.flxrs.dankchat.data.repo.stream import com.flxrs.dankchat.data.UserName -data class StreamData(val channel: UserName, val formattedData: String) \ No newline at end of file +data class StreamData( + val channel: UserName, + val formattedData: String, + val viewerCount: Int = 0, + val startedAt: String = "", + val category: String? = null, +) \ No newline at end of file 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 index 178d9d5a3..df27f5f9c 100644 --- 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 @@ -34,6 +34,8 @@ class StreamDataRepository( private var fetchTimerJob: Job? = null private val _streamData = MutableStateFlow>(persistentListOf()) val streamData: StateFlow> = _streamData.asStateFlow() + private val _fetchCount = java.util.concurrent.atomic.AtomicInteger(0) + val fetchCount: Int get() = _fetchCount.get() fun fetchStreamData(channels: List) { cancelStreamData() @@ -47,6 +49,7 @@ class StreamDataRepository( fetchTimerJob = timer(STREAM_REFRESH_RATE) { val currentSettings = streamsSettingsDataStore.settings.first() + _fetchCount.incrementAndGet() val data = dataRepository.getStreams(channels)?.map { val uptime = DateTimeUtils.calculateUptime(it.startedAt) val category = it.category @@ -54,7 +57,13 @@ class StreamDataRepository( ?.ifBlank { null } val formatted = dankChatPreferenceStore.formatViewersString(it.viewerCount, uptime, category) - StreamData(channel = it.userLogin, formattedData = formatted) + StreamData( + channel = it.userLogin, + formattedData = formatted, + viewerCount = it.viewerCount, + startedAt = it.startedAt, + category = it.category, + ) }.orEmpty() _streamData.value = data.toImmutableList() 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 fdd568c11..ac2da4c95 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 @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable enum class InputAction { - Search, LastMessage, Stream, RoomState, Fullscreen, HideInput + Search, LastMessage, Stream, RoomState, Fullscreen, HideInput, Debug } @Serializable 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 index 105486325..10d9581d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -628,6 +628,8 @@ fun MainScreen( }, onChangeRoomState = dialogViewModel::showRoomState, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, + onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, + debugMode = mainState.debugMode, onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, 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 index 0169531e0..1bcc36514 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -22,6 +22,7 @@ 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 @@ -59,22 +60,47 @@ class MainScreenViewModel( val uiState: StateFlow = combine( appearanceSettingsDataStore.settings, - developerSettingsDataStore.settings.map { it.repeatedSending }, + developerSettingsDataStore.settings, _isFullscreen, _gestureInputHidden, _gestureToolbarHidden, - ) { appearance, repeatedSending, isFullscreen, gestureInputHidden, gestureToolbarHidden -> + ) { appearance, developerSettings, isFullscreen, gestureInputHidden, gestureToolbarHidden -> MainScreenUiState( isFullscreen = isFullscreen, showInput = appearance.showInput, inputActions = appearance.inputActions.toImmutableList(), showCharacterCounter = appearance.showCharacterCounter, - isRepeatedSendEnabled = repeatedSending, + isRepeatedSendEnabled = developerSettings.repeatedSending, + debugMode = developerSettings.debugMode, gestureInputHidden = gestureInputHidden, gestureToolbarHidden = gestureToolbarHidden, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainScreenUiState()) + init { + 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 + } + } + } + } + } + fun isModeratorInChannel(channel: UserName?): Boolean = userStateRepository.isModeratorInChannel(channel) // Keyboard height persistence — debounced to avoid thrashing during animation @@ -147,6 +173,10 @@ class MainScreenViewModel( fun retryDataLoading(failedState: GlobalLoadingState.Failed) { channelDataCoordinator.retryDataLoading(failedState) } + + companion object { + private const val MAX_INPUT_ACTIONS = 4 + } } private data class KeyboardHeightUpdate(val heightPx: Int, val isLandscape: Boolean) @@ -158,6 +188,7 @@ data class MainScreenUiState( val inputActions: ImmutableList = persistentListOf(), val showCharacterCounter: Boolean = false, val isRepeatedSendEnabled: Boolean = false, + val debugMode: Boolean = false, val gestureInputHidden: Boolean = false, val gestureToolbarHidden: Boolean = false, ) { 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 index 89a3cc5ae..e5cad70e2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -11,6 +11,7 @@ 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.History @@ -198,12 +199,17 @@ private fun getOverflowItem( 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 -> true - InputAction.LastMessage -> inputEnabled && hasLastMessage - InputAction.Stream, InputAction.RoomState -> inputEnabled + InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true + InputAction.LastMessage -> inputEnabled && hasLastMessage + InputAction.Stream, InputAction.RoomState -> inputEnabled } /** 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 index d30063539..5bed67154 100644 --- 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 @@ -29,6 +29,8 @@ import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel import com.flxrs.dankchat.ui.main.input.ChatInputViewModel import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState import com.flxrs.dankchat.ui.main.sheet.InputSheetState +import com.flxrs.dankchat.ui.main.sheet.DebugInfoSheet +import com.flxrs.dankchat.ui.main.sheet.DebugInfoViewModel import com.flxrs.dankchat.ui.main.sheet.MoreActionsSheet import com.flxrs.dankchat.ui.main.sheet.SheetNavigationViewModel import kotlinx.coroutines.launch @@ -317,4 +319,13 @@ fun MainScreenDialogs( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) } + + if (inputSheetState is InputSheetState.DebugInfo) { + val debugInfoViewModel: DebugInfoViewModel = koinViewModel() + DebugInfoSheet( + viewModel = debugInfoViewModel, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismiss = sheetNavigationViewModel::closeInputSheet, + ) + } } 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 index c754eb7b3..9cdc0f4f7 100644 --- 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 @@ -52,6 +52,8 @@ fun ChatBottomBar( onToggleStream: () -> Unit, onChangeRoomState: () -> Unit, onSearchClick: () -> Unit, + onDebugInfoClick: () -> Unit = {}, + debugMode: Boolean = false, onNewWhisper: (() -> Unit)?, onInputActionsChanged: (ImmutableList) -> Unit, overflowExpanded: Boolean = false, @@ -102,6 +104,8 @@ fun ChatBottomBar( onChangeRoomState = onChangeRoomState, onInputActionsChanged = onInputActionsChanged, onSearchClick = onSearchClick, + onDebugInfoClick = onDebugInfoClick, + debugMode = debugMode, onNewWhisper = onNewWhisper, overflowExpanded = overflowExpanded, onOverflowExpandedChanged = onOverflowExpandedChanged, 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 index 842ec1d0b..77f774c92 100644 --- 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 @@ -32,6 +32,7 @@ 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 @@ -147,6 +148,8 @@ fun ChatInputLayout( onChangeRoomState: () -> Unit, onInputActionsChanged: (ImmutableList) -> Unit, onSearchClick: () -> Unit = {}, + onDebugInfoClick: () -> Unit = {}, + debugMode: Boolean = false, onNewWhisper: (() -> Unit)? = null, overflowExpanded: Boolean = false, onOverflowExpandedChanged: (Boolean) -> Unit = {}, @@ -179,11 +182,12 @@ fun ChatInputLayout( } // Filter to actions that would actually render based on current state - val effectiveActions = remember(inputActions, isModerator, hasStreamData, isStreamActive) { + val effectiveActions = remember(inputActions, isModerator, hasStreamData, isStreamActive, debugMode) { inputActions.filter { action -> when (action) { InputAction.Stream -> hasStreamData || isStreamActive InputAction.RoomState -> isModerator + InputAction.Debug -> debugMode else -> true } }.toImmutableList() @@ -341,6 +345,7 @@ fun ChatInputLayout( onChangeRoomState = onChangeRoomState, onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, + onDebugInfoClick = onDebugInfoClick, onSend = onSend, onVisibleActionsChanged = { visibleActions = it }, ) @@ -407,6 +412,7 @@ fun ChatInputLayout( InputAction.RoomState -> onChangeRoomState() InputAction.Fullscreen -> onToggleFullscreen() InputAction.HideInput -> onToggleInput() + InputAction.Debug -> onDebugInfoClick() } onOverflowExpandedChanged(false) }, @@ -421,6 +427,7 @@ fun ChatInputLayout( if (showConfigSheet) { InputActionConfigSheet( inputActions = inputActions, + debugMode = debugMode, onInputActionsChanged = onInputActionsChanged, onDismiss = { showConfigSheet = false }, ) @@ -431,6 +438,7 @@ fun ChatInputLayout( @Composable private fun InputActionConfigSheet( inputActions: ImmutableList, + debugMode: Boolean, onInputActionsChanged: (ImmutableList) -> Unit, onDismiss: () -> Unit, ) { @@ -438,7 +446,7 @@ private fun InputActionConfigSheet( val localEnabled = remember { mutableStateListOf(*inputActions.toTypedArray()) } - val disabledActions = InputAction.entries.filter { it !in localEnabled } + val disabledActions = InputAction.entries.filter { it !in localEnabled && (it != InputAction.Debug || debugMode) } val atLimit = localEnabled.size >= MAX_INPUT_ACTIONS ModalBottomSheet( @@ -570,6 +578,7 @@ private val InputAction.labelRes: Int InputAction.RoomState -> R.string.input_action_room_state InputAction.Fullscreen -> R.string.input_action_fullscreen InputAction.HideInput -> R.string.input_action_hide_input + InputAction.Debug -> R.string.input_action_debug } private val InputAction.icon: ImageVector @@ -580,6 +589,7 @@ private val InputAction.icon: ImageVector InputAction.RoomState -> Icons.Default.Shield InputAction.Fullscreen -> Icons.Default.Fullscreen InputAction.HideInput -> Icons.Default.VisibilityOff + InputAction.Debug -> Icons.Default.BugReport } @Composable @@ -620,6 +630,7 @@ private fun InputActionButton( onChangeRoomState: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, + onDebugInfoClick: () -> Unit = {}, modifier: Modifier = Modifier, ) { val (icon, contentDescription, onClick) = when (action) { @@ -639,12 +650,13 @@ private fun InputActionButton( ) InputAction.HideInput -> Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) + InputAction.Debug -> Triple(Icons.Default.BugReport, R.string.input_action_debug, onDebugInfoClick) } val actionEnabled = when (action) { - InputAction.Search, InputAction.Fullscreen, InputAction.HideInput -> true - InputAction.LastMessage -> enabled && hasLastMessage - InputAction.Stream, InputAction.RoomState -> enabled + InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true + InputAction.LastMessage -> enabled && hasLastMessage + InputAction.Stream, InputAction.RoomState -> enabled } IconButton( @@ -719,6 +731,7 @@ private fun InputActionsRow( onChangeRoomState: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, + onDebugInfoClick: () -> Unit = {}, onSend: () -> Unit, onVisibleActionsChanged: (ImmutableList) -> Unit, ) { @@ -788,6 +801,7 @@ private fun InputActionsRow( onChangeRoomState = onChangeRoomState, onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, + onDebugInfoClick = onDebugInfoClick, onSend = onSend, ) } @@ -817,6 +831,7 @@ private fun EndAlignedActionGroup( onChangeRoomState: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, + onDebugInfoClick: () -> Unit = {}, onSend: () -> Unit, ) { // Overflow Button (leading the end-aligned group) @@ -876,6 +891,7 @@ private fun EndAlignedActionGroup( onChangeRoomState = onChangeRoomState, onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, + onDebugInfoClick = onDebugInfoClick, modifier = Modifier.size(iconSize), ) } 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..74f47054b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoSheet.kt @@ -0,0 +1,99 @@ +package com.flxrs.dankchat.ui.main.sheet + +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.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.data.debug.DebugEntry +import com.flxrs.dankchat.utils.compose.BottomSheetNestedScrollConnection + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugInfoSheet( + viewModel: DebugInfoViewModel, + sheetState: SheetState, + onDismiss: () -> Unit, +) { + val sections by viewModel.sections.collectAsStateWithLifecycle() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + contentWindowInsets = { WindowInsets.statusBars }, + ) { + 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) + } + } + } + } +} + +@Composable +private fun DebugEntryRow(entry: DebugEntry) { + Row( + modifier = Modifier + .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..067816b59 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoViewModel.kt @@ -0,0 +1,19 @@ +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.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +class DebugInfoViewModel( + debugSectionRegistry: DebugSectionRegistry, +) : ViewModel() { + + val sections: StateFlow> = debugSectionRegistry.allSections() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) +} 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 index 359f53415..b7208717c 100644 --- 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 @@ -55,6 +55,10 @@ class SheetNavigationViewModel : ViewModel() { _inputSheetState.value = InputSheetState.MoreActions(messageId, fullMessage) } + fun openDebugInfo() { + _inputSheetState.value = InputSheetState.DebugInfo + } + fun closeInputSheet() { _inputSheetState.value = InputSheetState.Closed } @@ -91,6 +95,7 @@ sealed interface FullScreenSheetState { sealed interface InputSheetState { data object Closed : InputSheetState data object EmoteMenu : InputSheetState + data object DebugInfo : InputSheetState @Immutable data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState 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..fa5585acb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt @@ -0,0 +1,27 @@ +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.Fling -> 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/res/values-b+zh+Hant+TW/strings.xml b/app/src/main/res/values-b+zh+Hant+TW/strings.xml index 4b488ee90..b45511ded 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 @@ -383,7 +383,7 @@ 頻道資訊 開發者選項 除錯模式 - 提供已攔截的例外訊息 + 在輸入欄中顯示除錯分析操作 時間戳記格式 啟用文字朗讀 朗讀目前選取頻道的訊息 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 1239a2859..6846ce372 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -271,7 +271,7 @@ Дадзеныя канала Налады для распрацоўшчыкаў Рэжым адладкі - Адлюстроўваць інфармацыю пра запісаныя выключэнні + Паказваць дзеянне адладкавай аналітыкі ў панэлі ўводу Фармат часавых пазнак Уключыць сінтэзатар гаворкі Зачытвае паведамленні актыўнага канала diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 84af1f991..60e2b2977 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -276,7 +276,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Dades del canal Opcions de desenvolupador Mode de depuració - Proporciona informació per a qualsevol excepció que hagi estat atrapada + Mostra l\'acció d\'analítiques de depuració a la barra d\'entrada Format del temps Activar TTS Llegeix en veu alta missatges del canal actiu diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index c449c8d5e..dcf6b9c76 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -278,7 +278,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 Formát časových razítek Povolit TTS Čte zprávy aktivního kanálu diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 125c84dae..1b4a7d250 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -268,7 +268,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 Formatierung des Zeitstempels TTS aktivieren Liest Nachrichten des aktiven Kanals vor diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 1f4d79b53..5358e49f2 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -231,7 +231,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 Timestamp format Enable TTS Reads out messages of the active channel diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index af7a5250b..a9441fe97 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -231,7 +231,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 Timestamp format Enable TTS Reads out messages of the active channel diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 54c8af32f..67c7f6985 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -261,7 +261,7 @@ Channel data Developer options Debug mode - Provides information for any exceptions that have been caught + Show debug analytics action in input bar Timestamp format Enable TTS Reads out messages of the active channel diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 9136c48a7..a98cfd973 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -272,7 +272,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 Formato del tiempo Activar TTS Lee en voz alta mensajes del canal activo diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index cd94dff51..e656048c7 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -268,7 +268,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Kanavatiedot Kehittäjävaihtoehdot Virheenkorjaustila - Antaa tietoa mahdollisista kiinni jääneistä poikkeuksista + Näytä virheenkorjausanalytiikkatoiminto syöttöpalkissa Aikaleiman muoto Ota TTS käyttöön Lukee ääneen aktiivisen kanavan viestit diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 91ebd6e3b..7310668b1 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -271,7 +271,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 Format de l\'horodatage Activer le TTS Lis les messages à voix haute pour la chaîne actuelle diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 1e8148dfa..e1bd771bc 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -261,7 +261,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 Időformátum TTS engedélyezése Üzeneteket olvas fel az aktív csatornáról diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d8be33d58..9a8cf5737 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -265,7 +265,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 Formato marca oraria Abilita TTS Legge i messaggi del canale attivo diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 17bb2a45e..7c529e242 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -255,7 +255,7 @@ チャンネルデータ 開発者オプション デバッグモード - キャッチされた例外情報の提供 + 入力バーにデバッグ分析アクションを表示 タイムスタンプ形式 TTSの有効化 アクティブなチャンネルのメッセージを読み込む diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index a6a44ff03..1e6b79e56 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -381,7 +381,7 @@ Арна деректері Әзірлеуші параметрлері Дебью режімі - Ұсталған кез келген ерекшеліктер туралы ақпарат береді + Енгізу жолағында жөндеу аналитикасы әрекетін көрсету Таймстам пішімі TTS қосу Белсенді арнаның хабарларын оқиды diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index e9f70155c..86d47f0d2 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -381,7 +381,7 @@ ଚ୍ୟାନେଲ ତଥ୍ୟ | ଡେଵେଲପର୍ ଵିକଳ୍ପ କିବଗ୍ ମୋଡ୍ | - ଧରାପଡିଥିବା ଯେକ any ଣସି ବ୍ୟତିକ୍ରମ ପାଇଁ ସୂଚନା ପ୍ରଦାନ କରେ | + ଇନପୁଟ ବାରରେ ଡିବଗ ଆନାଲିଟିକ୍ସ କାର୍ଯ୍ୟ ଦେଖାନ୍ତୁ ଟାଇମଷ୍ଟ୍ୟାମ୍ପ ଫର୍ମାଟ୍ | TTS ସକ୍ଷମ କରନ୍ତୁ | ସକ୍ରିୟ ଚ୍ୟାନେଲର ବାର୍ତ୍ତା ପ Read ଼େ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index e061c5b1b..b66fae808 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -275,7 +275,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 Format znacznika czasu Włącz TTS Odczytuje na głos wiadomości aktywnego kanału diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 8ec109d24..31c1cf69a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -266,7 +266,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 Formato de data e hora Ativar Texto-para-voz Lê as mensagens do canal ativo diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 12e8fee01..bd6242535 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -266,7 +266,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 Formato do carimbo da hora Habilitar TTS Lê as mensagens do canal ativo diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index c25d8152c..a13f0bdf3 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -276,7 +276,7 @@ Данные канала Настройки разработчика Режим отладки - Отображать информацию о записанных исключениях + Показывать действие отладочной аналитики в панели ввода Формат временных меток Включить синтезатор речи Зачитывает сообщения активного канала diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 128a2ed0e..f854f0d26 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -366,7 +366,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e154444c4..8cd90ab71 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -267,7 +267,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 Zaman damgası biçimi TTS\'i etkinleştir Etkin kanalın mesajlarını okur diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index b0dd0d502..60c91fc69 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -278,7 +278,7 @@ Дані каналу Налаштування розробника Режим відлагодження - Надає інформацію про помилки + Показувати дію аналітики налагодження в панелі введення Формат часових міток Увімкнути синтезатор мовлення Зачитує повідомлення в активному чаті diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e77ecd7b5..004a244d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,7 @@ Channel settings Fullscreen Hide input + Debug Configure actions Emote only Subscriber only @@ -420,7 +421,7 @@ Developer options debug_mode_key Debug mode - Provides information for any exceptions that have been caught + Show debug analytics action in input bar timestamp_format_key Timestamp format tts_key From 2e533c24eacc8587fe807cf499578d224c9ff660 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 19:43:32 +0100 Subject: [PATCH 124/349] fix(pubsub): Rewrite PubSubManager to be reactive, fixing PubSub not reconnecting after re-login --- .../dankchat/data/repo/chat/ChatConnector.kt | 9 -- .../dankchat/data/repo/chat/ChatRepository.kt | 5 +- .../data/twitch/pubsub/PubSubManager.kt | 105 +++++++----------- 3 files changed, 44 insertions(+), 75 deletions(-) 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 index e22414a04..e482b3618 100644 --- 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 @@ -56,10 +56,6 @@ class ChatConnector( } fun connectAndJoin(channels: List) { - if (!pubSubManager.connected) { - pubSubManager.start() - } - if (!readConnection.connected) { readConnection.connect() writeConnection.connect() @@ -73,7 +69,6 @@ class ChatConnector( fun closeAndReconnect(channels: List) = scope.launch { readConnection.close() writeConnection.close() - pubSubManager.close() eventSubManager.close() connectAndJoin(channels) } @@ -99,10 +94,6 @@ class ChatConnector( readConnection.joinChannel(channel) } - fun addPubSubChannel(channel: UserName) { - pubSubManager.addChannel(channel) - } - fun partChannel(channel: UserName) { readConnection.partChannel(channel) pubSubManager.removeChannel(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 05a9f7515..f14521480 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 @@ -43,7 +43,7 @@ class ChatRepository( fun setActiveChannel(channel: UserName?) = chatChannelProvider.setActiveChannel(channel) - fun joinChannel(channel: UserName, listenToPubSub: Boolean = true): List { + fun joinChannel(channel: UserName): List { val currentChannels = channels.value.orEmpty() if (channel in currentChannels) { return currentChannels @@ -56,9 +56,6 @@ class ChatRepository( chatMessageRepository.clearMessages(channel) chatConnector.joinIrcChannel(channel) - if (listenToPubSub) { - chatConnector.addPubSubChannel(channel) - } return updatedChannels } 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 0a25d6657..196483620 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 @@ -2,18 +2,23 @@ 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.toUserId +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.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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel as CoroutineChannel 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 @@ -25,9 +30,9 @@ import org.koin.core.annotation.Single @Single class PubSubManager( private val channelRepository: ChannelRepository, + private val chatChannelProvider: ChatChannelProvider, private val developerSettingsDataStore: DeveloperSettingsDataStore, private val authDataStore: AuthDataStore, - private val preferenceStore: DankChatPreferenceStore, @Named(type = WebSocketOkHttpClient::class) private val client: OkHttpClient, private val json: Json, dispatchersProvider: DispatchersProvider, @@ -35,7 +40,7 @@ class PubSubManager( private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) 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 @@ -44,36 +49,21 @@ class PubSubManager( val connectedAndHasWhisperTopic: Boolean get() = connections.any { it.connected && it.hasWhisperTopic } - fun start() { - if (!authDataStore.isLoggedIn) { - return - } - - val userId = authDataStore.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)) - } - } - - } + 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) return@collect + val resolved = channelRepository.getChannels(channels) + val topics = buildTopics(userId, resolved, shouldUsePubSub) + listen(topics) } - listen(topics) } } @@ -81,31 +71,6 @@ 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 (!authDataStore.isLoggedIn) { - return@launch - } - - val userId = authDataStore.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) } @@ -120,6 +85,19 @@ class PubSubManager( resetCollectionWith() } + 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)) + } + } + if (shouldUsePubSub) { + add(PubSubTopic.Whispers(uid)) + } + } + private fun listen(topics: Set) { val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return val remainingTopics = connections.fold(topics) { acc, conn -> @@ -150,6 +128,13 @@ class PubSubManager( } } + 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() @@ -170,8 +155,4 @@ class PubSubManager( } } } - - companion object { - private val TAG = PubSubManager::class.java.simpleName - } } From faa5dd422b5d75ac570fc4e55a10c5559cf35132 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 20:40:10 +0100 Subject: [PATCH 125/349] feat(helix): Add Helix API message sending as developer setting, add user:write:chat scope, track API stats in debug sheet --- .../dankchat/data/api/auth/AuthApiClient.kt | 1 + .../flxrs/dankchat/data/api/helix/HelixApi.kt | 8 ++ .../dankchat/data/api/helix/HelixApiClient.kt | 12 ++ .../data/api/helix/HelixApiException.kt | 2 + .../dankchat/data/api/helix/HelixApiStats.kt | 19 +++ .../data/api/helix/dto/SendChatMessageDto.kt | 26 ++++ .../dankchat/data/debug/ApiDebugSection.kt | 37 +++++ .../data/debug/SessionDebugSection.kt | 6 + .../data/repo/chat/ChatMessageRepository.kt | 19 +++ .../data/repo/chat/ChatMessageSender.kt | 133 ++++++++++++++++++ .../dankchat/data/repo/chat/ChatRepository.kt | 35 +---- .../twitch/command/TwitchCommandRepository.kt | 2 + .../com/flxrs/dankchat/di/NetworkModule.kt | 9 +- .../preferences/developer/ChatSendProtocol.kt | 9 ++ .../developer/DeveloperSettings.kt | 1 + .../developer/DeveloperSettingsScreen.kt | 15 ++ .../developer/DeveloperSettingsViewModel.kt | 2 + .../chat/message/MessageOptionsViewModel.kt | 2 +- .../ui/main/input/ChatInputViewModel.kt | 6 +- .../main/res/values-b+zh+Hant+TW/strings.xml | 3 + app/src/main/res/values-be-rBY/strings.xml | 3 + app/src/main/res/values-ca/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en-rAU/strings.xml | 3 + app/src/main/res/values-en-rGB/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 3 + app/src/main/res/values-fi-rFI/strings.xml | 3 + app/src/main/res/values-fr-rFR/strings.xml | 3 + app/src/main/res/values-hu-rHU/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja-rJP/strings.xml | 3 + app/src/main/res/values-kk-rKZ/strings.xml | 3 + app/src/main/res/values-or-rIN/strings.xml | 3 + app/src/main/res/values-pl-rPL/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt-rPT/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values-sr/strings.xml | 3 + app/src/main/res/values-tr-rTR/strings.xml | 3 + app/src/main/res/values-uk-rUA/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 43 files changed, 381 insertions(+), 35 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiStats.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/SendChatMessageDto.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageSender.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/ChatSendProtocol.kt 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 82fd04aae..d371a31f4 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 @@ -67,6 +67,7 @@ class AuthApiClient(private val authApi: AuthApi, private val json: Json) { "user:read:blocked_users", "user:read:chat", "user:read:emotes", + "user:write:chat", "whispers:edit", "whispers:read", ) 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 ac4be93ed..4a5f69ef0 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 @@ -9,6 +9,7 @@ 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.data.auth.AuthDataStore @@ -297,4 +298,11 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au bearerAuth(oAuth) parameter("broadcaster_id", broadcasterId) } + + suspend fun postChatMessage(request: SendChatMessageRequestDto): HttpResponse? = ktorClient.post("chat/messages") { + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 4a3a7acfa..3c4a53190 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 @@ -17,6 +17,8 @@ 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.SendChatMessageRequestDto +import com.flxrs.dankchat.data.api.helix.dto.SendChatMessageResponseDto 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 @@ -270,6 +272,14 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { .data } + suspend fun postChatMessage(request: SendChatMessageRequestDto): Result = runCatching { + helixApi.postChatMessage(request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } + private inline fun pageAsFlow(amountToFetch: Int, crossinline request: suspend (cursor: String?) -> HttpResponse?): Flow> = flow { val initialPage = request(null) .throwHelixApiErrorOnFailure() @@ -364,12 +374,14 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { HttpStatusCode.UnprocessableEntity -> when (request.url.encodedPath) { "/helix/moderation/moderators" -> HelixError.TargetIsVip + "/helix/chat/messages" -> HelixError.MessageTooLarge 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 } 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 49e3112a9..c70f3db78 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 @@ -43,4 +43,6 @@ sealed interface 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..11f713707 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiStats.kt @@ -0,0 +1,19 @@ +package com.flxrs.dankchat.data.api.helix + +import org.koin.core.annotation.Single +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +@Single +class HelixApiStats { + private val _totalRequests = AtomicInteger(0) + private val _statusCounts = ConcurrentHashMap() + + val totalRequests: Int get() = _totalRequests.get() + val statusCounts: Map get() = _statusCounts.mapValues { it.value.get() } + + fun recordResponse(statusCode: Int) { + _totalRequests.incrementAndGet() + _statusCounts.getOrPut(statusCode) { AtomicInteger(0) }.incrementAndGet() + } +} 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/debug/ApiDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt new file mode 100644 index 000000000..08ee28d52 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt @@ -0,0 +1,37 @@ +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 { it.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/SessionDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt index 900df91a4..f71b68097 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt @@ -2,6 +2,7 @@ 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 @@ -13,6 +14,7 @@ import kotlin.time.TimeSource class SessionDebugSection( private val chatMessageRepository: ChatMessageRepository, private val chatChannelProvider: ChatChannelProvider, + private val developerSettingsDataStore: DeveloperSettingsDataStore, ) : DebugSection { private val startMark = TimeSource.Monotonic.markNow() @@ -42,7 +44,11 @@ class SessionDebugSection( 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/repo/chat/ChatMessageRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageRepository.kt index 54fa6d605..e9944c282 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -39,7 +40,25 @@ class ChatMessageRepository( private val messages = ConcurrentHashMap>>() private val _chatLoadingFailures = MutableStateFlow(emptySet()) private val _sessionMessageCount = java.util.concurrent.atomic.AtomicInteger(0) + private val _ircSentCount = java.util.concurrent.atomic.AtomicInteger(0) + private val _helixSentCount = java.util.concurrent.atomic.AtomicInteger(0) + private val _sendFailureCount = java.util.concurrent.atomic.AtomicInteger(0) + val sessionMessageCount: Int get() = _sessionMessageCount.get() + val ircSentCount: Int get() = _ircSentCount.get() + val helixSentCount: Int get() = _helixSentCount.get() + val sendFailureCount: Int get() = _sendFailureCount.get() + + fun incrementSentMessageCount(protocol: ChatSendProtocol) { + when (protocol) { + ChatSendProtocol.IRC -> _ircSentCount.incrementAndGet() + ChatSendProtocol.Helix -> _helixSentCount.incrementAndGet() + } + } + + fun incrementSendFailureCount() { + _sendFailureCount.incrementAndGet() + } private val scrollBackLengthFlow = chatSettingsDataStore.debouncedScrollBack .onEach { length -> 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..532493a0e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageSender.kt @@ -0,0 +1,133 @@ +package com.flxrs.dankchat.data.repo.chat + +import android.util.Log +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 org.koin.core.annotation.Single + +@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 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, "Not logged in.") + return + } + val broadcasterId = channelRepository.getChannel(channel)?.id ?: run { + postError(channel, "Could not resolve channel ID for $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 reason = response.dropReason + val msg = when (reason) { + null -> "Message was not sent." + else -> "Message dropped: ${reason.message} (${reason.code})" + } + postError(channel, msg) + } + } + }, + onFailure = { throwable -> + Log.e(TAG, "Helix send failed", throwable) + postError(channel, throwable.toSendErrorMessage()) + }, + ) + } + + private fun postError(channel: UserName, message: String) { + chatMessageRepository.addSystemMessage(channel, SystemMessageType.Custom(message)) + 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.toSendErrorMessage(): String = when (this) { + is HelixApiException -> when (error) { + HelixError.NotLoggedIn -> "Not logged in." + HelixError.MissingScopes -> "Missing user:write:chat scope. Please re-login." + HelixError.UserNotAuthorized -> "Not authorized to send messages in this channel." + HelixError.MessageTooLarge -> "Message is too large." + HelixError.ChatMessageRateLimited -> "Rate limited. Try again in a moment." + HelixError.Forwarded -> message ?: "Unknown error." + else -> message ?: "Unknown error." + } + + else -> message ?: "Unknown error." + } + + companion object { + private val TAG = ChatMessageSender::class.java.simpleName + } +} 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 f14521480..ae9cf5f24 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 @@ -13,7 +13,6 @@ import com.flxrs.dankchat.data.twitch.message.SystemMessageType import com.flxrs.dankchat.data.twitch.message.WhisperMessage import com.flxrs.dankchat.data.twitch.message.toChatItem import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.utils.extensions.INVISIBLE_CHAR import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import org.koin.core.annotation.Single @@ -30,6 +29,7 @@ class ChatRepository( private val emoteRepository: EmoteRepository, private val channelRepository: ChannelRepository, private val messageProcessor: MessageProcessor, + private val chatMessageSender: ChatMessageSender, private val authDataStore: AuthDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, ) { @@ -78,10 +78,9 @@ class ChatRepository( channelRepository.initRoomState(channel) } - fun sendMessage(input: String, replyId: String? = null) { + suspend fun sendMessage(input: String, replyId: String? = null, forceIrc: Boolean = false) { val channel = chatChannelProvider.activeChannel.value ?: return - val preparedMessage = prepareMessage(channel, input, replyId) ?: return - chatConnector.sendRaw(preparedMessage) + chatMessageSender.send(channel, input, replyId, forceIrc) } fun fakeWhisperIfNecessary(input: String) { @@ -151,32 +150,4 @@ class ChatRepository( messageProcessor.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 currentLastMessage = chatEventProcessor.getLastMessage(channel).orEmpty() - - val messageWithSuffix = if (currentLastMessage == trimmedMessage) { - val startIndex = if (trimmedMessage.startsWith('/') || trimmedMessage.startsWith('.')) { - trimmedMessage.indexOf(' ').let { if (it == -1) 0 else it + 1 } - } else { - 0 - } - val spaceIndex = trimmedMessage.indexOf(' ', startIndex) - - when { - spaceIndex != -1 -> trimmedMessage.replaceRange(spaceIndex, spaceIndex + 1, " ") - else -> "$trimmedMessage $INVISIBLE_CHAR" - } - } else { - trimmedMessage - } - - chatEventProcessor.setLastMessage(channel, messageWithSuffix) - return "${replyIdOrBlank}PRIVMSG #$channel :$messageWithSuffix" - } } 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 8c85603db..03615fd70 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 @@ -694,6 +694,8 @@ class TwitchCommandRepository( HelixError.MessageAlreadyProcessed -> "The message has already been processed." HelixError.MessageNotFound -> "The target message was not found." + HelixError.MessageTooLarge -> "Your message was too long." + HelixError.ChatMessageRateLimited -> "You are being rate-limited. Try again in a moment." HelixError.Unknown -> GENERIC_ERROR_MESSAGE } } 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 7873cfeb9..0027467ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -8,6 +8,7 @@ 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 @@ -23,6 +24,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 @@ -115,11 +117,16 @@ class NetworkModule { }) @Single - fun provideHelixApi(ktorClient: HttpClient, authDataStore: AuthDataStore) = HelixApi(ktorClient.config { + fun provideHelixApi(ktorClient: HttpClient, authDataStore: AuthDataStore, helixApiStats: HelixApiStats) = HelixApi(ktorClient.config { defaultRequest { url(HELIX_BASE_URL) header("Client-ID", authDataStore.clientId) } + install(ResponseObserver) { + onResponse { response -> + helixApiStats.recordResponse(response.status.value) + } + } }, authDataStore) @Single 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..d8b018cd8 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,6 +10,7 @@ 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 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 index b40fcf07a..857d9d4ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -189,6 +189,21 @@ private fun DeveloperSettingsContent( ) } + PreferenceCategory(title = stringResource(R.string.preference_chat_send_protocol_category)) { + 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)) + }, + ) + } + PreferenceCategory(title = "EventSub") { if (!settings.isPubSubShutdown) { SwitchPreferenceItem( 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 e35f3e998..7cc19cafd 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 @@ -55,6 +55,7 @@ class DeveloperSettingsViewModel( } is DeveloperSettingsInteraction.EventSubDebugOutput -> developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } + is DeveloperSettingsInteraction.ChatSendProtocolChanged -> developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } is DeveloperSettingsInteraction.RestartRequired -> _events.emit(DeveloperSettingsEvent.RestartRequired) is DeveloperSettingsInteraction.ResetOnboarding -> { onboardingDataStore.update { @@ -93,6 +94,7 @@ sealed interface 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 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 index 879c0a44b..452e8135e 100644 --- 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 @@ -102,7 +102,7 @@ class MessageOptionsViewModel( }.getOrNull() ?: return when (result) { - is CommandResult.IrcCommand -> chatRepository.sendMessage(message) + is CommandResult.IrcCommand -> chatRepository.sendMessage(message, forceIrc = true) is CommandResult.AcceptedTwitchCommand -> result.response?.let { chatRepository.makeAndPostCustomSystemMessage(it, activeChannel) } else -> Unit } 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 index 6f157b3f3..30889251c 100644 --- 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 @@ -364,7 +364,11 @@ class ChatInputViewModel( is CommandResult.Accepted, is CommandResult.Blocked -> Unit - is CommandResult.IrcCommand, + is CommandResult.IrcCommand -> { + chatRepository.sendMessage(message, replyIdOrNull, forceIrc = true) + setReplying(false) + } + is CommandResult.NotFound -> { chatRepository.sendMessage(message, replyIdOrNull) setReplying(false) 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 b45511ded..7d6415ca2 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 @@ -445,6 +445,9 @@ 自訂登入 繞過Twitch指令處理 取消攔截Twitch指令並改為傳送至聊天室 + 聊天傳送協議 + 使用 Helix API 傳送 + 透過 Twitch Helix API 傳送聊天訊息,而非 IRC 7TV即時表情符號更新 即時表情更新背景行為 更新將停止於%1$s後。\n降低這個數字可能會提升電池壽命。 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 6846ce372..d4915a246 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -333,6 +333,9 @@ Індывідуальны лагін Абыход апрацоўкі каманд Twitch Адключае перахопліванне каманд Twitch і адпраўляе іх у чат + Пратакол адпраўкі чата + Выкарыстоўваць Helix API для адпраўкі + Адпраўляць паведамленні чата праз Twitch Helix API замест IRC Абнаўленні смайлаў 7TV у рэальным часе Паводзіны фону абнаўленняў смайлаў у рэальным часе Абнаўленні спыняюцца пасля %1$s.\nЗніжэнне гэтага значэння можа павялічыць час работы ад батарэі. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 60e2b2977..742b94e84 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -338,6 +338,9 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index dcf6b9c76..8a9b532ba 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -340,6 +340,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. diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 1b4a7d250..fc03e4442 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -330,6 +330,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. diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 5358e49f2..b04dea274 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -507,6 +507,9 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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. diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index a9441fe97..85f19a52e 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -508,6 +508,9 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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. diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 67c7f6985..4103dfea2 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -323,6 +323,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. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index a98cfd973..49d7c7e7e 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -334,6 +334,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. diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index e656048c7..6ccf235a8 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -330,6 +330,9 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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. diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 7310668b1..b6c1ced50 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -333,6 +333,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. diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index e1bd771bc..b82de63a0 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -323,6 +323,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. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9a8cf5737..2b176659b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -327,6 +327,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. diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 7c529e242..1f3e0faec 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -317,6 +317,9 @@ カスタムログイン Twitchコマンドの処理をバイパスする Twitchコマンドの通信切断を無効にし、代わりにチャットに送信します + チャット送信プロトコル + Helix APIで送信する + IRCの代わりにTwitch Helix API経由でチャットメッセージを送信します 7TVライブエモートの更新 ライブエモートはバックグラウンドで更新中 %1$s後にアップデートを停止します。\n数字を下げることでバッテリー寿命を延ばせるかもしれません。 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 1e6b79e56..af8432fa7 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -443,6 +443,9 @@ Арнайы кіру Twitch пәрменін өңдеуді айналып өтіңіз Twitch пәрмендерін ұстауды өшіреді және олардың орнына чатқа жібереді + Чат жіберу протоколы + Жіберу үшін Helix API пайдалану + Чат хабарламаларын IRC орнына Twitch Helix API арқылы жіберу 7TV тікелей эмоция жаңартулары Тікелей эмоция фондық әрекетті жаңартады Жаңартулар %1$s кейін тоқтайды.\nБұл санды азайту батареяның қызмет ету мерзімін ұзартуы мүмкін. diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 86d47f0d2..740531a59 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -443,6 +443,9 @@ କଷ୍ଟମ୍ ଲଗଇନ୍ | ଟ୍ୱିଚ୍ କମାଣ୍ଡ ହ୍ୟାଣ୍ଡଲିଂକୁ ବାଇପାସ୍ କରନ୍ତୁ | Twitch ନିର୍ଦ୍ଦେଶଗୁଡ଼ିକର ବାଧା ସୃଷ୍ଟି କରିବାକୁ ଏବଂ ସେଥିପାଇଁ ଚାଟ୍ କରିବାକୁ ସେମାନଙ୍କୁ ପଠାଏ | + ଚାଟ୍ ପଠାଇବା ପ୍ରୋଟୋକଲ୍ + ପଠାଇବା ପାଇଁ Helix API ବ୍ୟବହାର କରନ୍ତୁ + IRC ବଦଳରେ Twitch Helix API ମାଧ୍ୟମରେ ଚାଟ୍ ବାର୍ତ୍ତା ପଠାନ୍ତୁ 7TV ଲାଇଭ୍ ଇମୋଟେଟ୍ ଅପଡେଟ୍ | ଲାଇଭ୍ ଇମୋଟ୍ ଅପଡେଟ୍ ପୃଷ୍ଠଭୂମି ଆଚରଣ | ପରେ ଅଟକି ଯାଅ | %1$s.\nଏହି ସଂଖ୍ୟା ହ୍ରାସ କରିବା ବ୍ୟାଟେରୀ ଜୀବନ ବୃଦ୍ଧି କରିପାରେ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index b66fae808..4582ec6e2 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -337,6 +337,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. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 31c1cf69a..798afe7b9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -328,6 +328,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. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index bd6242535..c72e11458 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -328,6 +328,9 @@ 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. diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index a13f0bdf3..37ebfdcc0 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -338,6 +338,9 @@ Пользовательский логин Обход обработки команд Twitch Отключает перехват команд Twitch и отправляет их в чат + Протокол отправки чата + Использовать Helix API для отправки + Отправлять сообщения чата через Twitch Helix API вместо IRC Обновления 7TV эмоций в реальном времени Фоновые обновления эмоций Обновления останавливаются после %1$s.\nУменьшение этого значения может увеличить время автономной работы. diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index f854f0d26..4811dcfad 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -428,6 +428,9 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Прилагођена пријава Заобиђи Twitch обраду команди Искључује пресретање Twitch команди и шаље их директно у чат + Протокол за слање порука + Користи Helix API за слање + Шаље поруке у чату преко Twitch Helix API уместо IRC 7TV ажурирања емотикона уживо Понашање ажурирања емотикона у позадини Ажурирања се заустављају после %1$s.\nСмањивање овог броја може продужити трајање батерије. diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 8cd90ab71..8a4ea1822 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -329,6 +329,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. diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 60c91fc69..6c883498e 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -340,6 +340,9 @@ Користувацький логін Обробка команд обходу Twitch Вимикає перехоплення команд Twitch і надсилає їх до чату + Протокол надсилання чату + Використовувати Helix API для надсилання + Надсилати повідомлення чату через Twitch Helix API замість IRC Оновлення емоцій в прямому ефірі 7TV Живі емоти оновлюють фонову поведінку Оновлення припиняються після %1$s.\nЗменшення цього числа може збільшити час автономної роботи. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 004a244d4..2ffae05d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -514,6 +514,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 From 867e72292bf9934e8553799f2399330a7e5fe3b6 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 20:46:58 +0100 Subject: [PATCH 126/349] fix(auth): Defer auth dialogs until MainScreen is resumed, preventing navigation conflicts --- .../ui/main/MainScreenEventHandler.kt | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) 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 index 585e93e5b..ed584cf75 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import androidx.core.content.getSystemService import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R @@ -81,33 +84,38 @@ fun MainScreenEventHandler( } // Collect auth events from AuthStateCoordinator + // Only process when RESUMED to avoid showing dialogs while another screen is on top. + // Events are buffered in the Channel and consumed once MainScreen becomes visible again. + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(Unit) { - authStateCoordinator.events.collect { event -> - when (event) { - is AuthEvent.LoggedIn -> { - launch { - delay(2000) - snackbarHostState.currentSnackbarData?.dismiss() + 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, + ) } - snackbarHostState.showSnackbar( - message = resources.getString(R.string.snackbar_login, event.userName), - duration = SnackbarDuration.Short, - ) - } - is AuthEvent.ScopesOutdated -> { - dialogViewModel.showLoginOutdated(event.userName) - } + is AuthEvent.ScopesOutdated -> { + dialogViewModel.showLoginOutdated(event.userName) + } - AuthEvent.TokenInvalid -> { - dialogViewModel.showLoginExpired() - } + AuthEvent.TokenInvalid -> { + dialogViewModel.showLoginExpired() + } - AuthEvent.ValidationFailed -> { - snackbarHostState.showSnackbar( - message = resources.getString(R.string.oauth_verify_failed), - duration = SnackbarDuration.Short, - ) + AuthEvent.ValidationFailed -> { + snackbarHostState.showSnackbar( + message = resources.getString(R.string.oauth_verify_failed), + duration = SnackbarDuration.Short, + ) + } } } } From c335c99711c76b90230df6b092f9a769794afe69 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 20:56:51 +0100 Subject: [PATCH 127/349] fix(input): Wire up repeated send long-press gesture on send button --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 2 + .../dankchat/ui/main/input/ChatBottomBar.kt | 4 ++ .../dankchat/ui/main/input/ChatInputLayout.kt | 71 +++++++++++++++---- 3 files changed, 63 insertions(+), 14 deletions(-) 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 index 10d9581d9..10ba5cede 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -640,6 +640,8 @@ fun MainScreen( onHelperTextHeightChanged = { helperTextHeightPx = it }, isInSplitLayout = useWideSplitLayout, instantHide = isHistorySheet, + isRepeatedSendEnabled = mainState.isRepeatedSendEnabled, + onRepeatedSendChanged = chatInputViewModel::setRepeatedSend, tourState = remember(featureTourState.currentTourStep, featureTourState.forceOverflowOpen) { TourOverlayState( inputActionsTooltipState = if (featureTourState.currentTourStep == TourStep.InputActions) featureTourViewModel.inputActionsTooltipState else null, 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 index 9cdc0f4f7..164e7c842 100644 --- 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 @@ -63,6 +63,8 @@ fun ChatBottomBar( isInSplitLayout: Boolean = false, instantHide: Boolean = false, tourState: TourOverlayState = TourOverlayState(), + isRepeatedSendEnabled: Boolean = false, + onRepeatedSendChanged: (Boolean) -> Unit = {}, ) { Column(modifier = Modifier.fillMaxWidth()) { AnimatedVisibility( @@ -111,6 +113,8 @@ fun ChatBottomBar( onOverflowExpandedChanged = onOverflowExpandedChanged, showQuickActions = !isSheetOpen, tourState = tourState, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onRepeatedSendChanged = onRepeatedSendChanged, modifier = Modifier.onGloballyPositioned { coordinates -> onInputHeightChanged(coordinates.size.height) } 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 index 77f774c92..40f780a2e 100644 --- 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 @@ -11,6 +11,7 @@ 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.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -80,6 +81,7 @@ 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.pointer.pointerInput import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.layout @@ -155,6 +157,8 @@ fun ChatInputLayout( onOverflowExpandedChanged: (Boolean) -> Unit = {}, showQuickActions: Boolean = true, tourState: TourOverlayState = TourOverlayState(), + isRepeatedSendEnabled: Boolean = false, + onRepeatedSendChanged: (Boolean) -> Unit = {}, ) { val focusRequester = remember { FocusRequester() } val hint = when (inputState) { @@ -347,6 +351,8 @@ fun ChatInputLayout( onToggleInput = onToggleInput, onDebugInfoClick = onDebugInfoClick, onSend = onSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onRepeatedSendChanged = onRepeatedSendChanged, onVisibleActionsChanged = { visibleActions = it }, ) } @@ -595,25 +601,54 @@ private val InputAction.icon: ImageVector @Composable private fun SendButton( enabled: Boolean, + isRepeatedSendEnabled: Boolean, onSend: () -> Unit, + onRepeatedSendChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { - val contentColor = if (enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + val contentColor = when { + !enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + else -> MaterialTheme.colorScheme.primary } - IconButton( - onClick = onSend, - enabled = enabled, - modifier = modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.send_hint), - tint = contentColor - ) + when { + enabled && isRepeatedSendEnabled -> { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(40.dp) + .pointerInput(Unit) { + detectTapGestures( + onTap = { onSend() }, + onLongPress = { onRepeatedSendChanged(true) }, + onPress = { + tryAwaitRelease() + onRepeatedSendChanged(false) + }, + ) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.send_hint), + tint = contentColor + ) + } + } + + else -> { + IconButton( + onClick = onSend, + enabled = enabled, + modifier = modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.send_hint), + tint = contentColor + ) + } + } } } @@ -733,6 +768,8 @@ private fun InputActionsRow( onToggleInput: () -> Unit, onDebugInfoClick: () -> Unit = {}, onSend: () -> Unit, + isRepeatedSendEnabled: Boolean = false, + onRepeatedSendChanged: (Boolean) -> Unit = {}, onVisibleActionsChanged: (ImmutableList) -> Unit, ) { BoxWithConstraints( @@ -803,6 +840,8 @@ private fun InputActionsRow( onToggleInput = onToggleInput, onDebugInfoClick = onDebugInfoClick, onSend = onSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onRepeatedSendChanged = onRepeatedSendChanged, ) } } @@ -833,6 +872,8 @@ private fun EndAlignedActionGroup( onToggleInput: () -> Unit, onDebugInfoClick: () -> Unit = {}, onSend: () -> Unit, + isRepeatedSendEnabled: Boolean = false, + onRepeatedSendChanged: (Boolean) -> Unit = {}, ) { // Overflow Button (leading the end-aligned group) if (showQuickActions) { @@ -899,7 +940,9 @@ private fun EndAlignedActionGroup( // Send Button (Right) SendButton( enabled = canSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, onSend = onSend, + onRepeatedSendChanged = onRepeatedSendChanged, ) } From be7b9dfb086c7ec9920df221040802cecc4a5c4e Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 21:16:54 +0100 Subject: [PATCH 128/349] feat(debug): Add app memory and thread debug section --- .../dankchat/data/debug/AppDebugSection.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt 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..11a01f191 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt @@ -0,0 +1,47 @@ +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 + +@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 totalAppMemory = heapUsed + nativeAllocated + + DebugSectionSnapshot( + title = baseTitle, + entries = listOf( + DebugEntry("Total app memory", formatBytes(totalAppMemory)), + DebugEntry("JVM heap", "${formatBytes(heapUsed)} / ${formatBytes(heapMax)}"), + DebugEntry("Native heap", "${formatBytes(nativeAllocated)} / ${formatBytes(nativeTotal)}"), + DebugEntry("Threads", "${Thread.activeCount()}"), + ), + ) + } + } + + private fun formatBytes(bytes: Long): String { + val mb = bytes / (1024.0 * 1024.0) + return "%.1f MB".format(mb) + } +} From f336f9573cf5b11a89ebf3bb781e259c81b64523 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 22:44:09 +0100 Subject: [PATCH 129/349] feat(mod-actions): Expand room state dialog into mod actions menu with shield mode, clear chat, announce, shoutout, commercial, raid, and stream marker --- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 7 + .../dankchat/data/api/helix/HelixApiClient.kt | 8 + .../appearance/AppearanceSettings.kt | 4 +- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 1 - .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 1 - .../com/flxrs/dankchat/ui/main/MainScreen.kt | 8 +- .../dankchat/ui/main/QuickActionsMenu.kt | 6 +- .../ui/main/dialog/DialogStateViewModel.kt | 19 +- .../ui/main/dialog/MainScreenDialogs.kt | 34 +- ...RoomStateDialog.kt => ModActionsDialog.kt} | 328 +++++++++++++++--- .../ui/main/dialog/ModActionsViewModel.kt | 39 +++ .../dankchat/ui/main/input/ChatBottomBar.kt | 4 +- .../dankchat/ui/main/input/ChatInputLayout.kt | 26 +- .../main/res/values-b+zh+Hant+TW/strings.xml | 19 +- app/src/main/res/values-be-rBY/strings.xml | 19 +- app/src/main/res/values-ca/strings.xml | 19 +- app/src/main/res/values-cs/strings.xml | 19 +- app/src/main/res/values-de-rDE/strings.xml | 19 +- app/src/main/res/values-en-rAU/strings.xml | 19 +- app/src/main/res/values-en-rGB/strings.xml | 19 +- app/src/main/res/values-en/strings.xml | 19 +- app/src/main/res/values-es-rES/strings.xml | 19 +- app/src/main/res/values-fi-rFI/strings.xml | 19 +- app/src/main/res/values-fr-rFR/strings.xml | 19 +- app/src/main/res/values-hu-rHU/strings.xml | 19 +- app/src/main/res/values-it/strings.xml | 19 +- app/src/main/res/values-ja-rJP/strings.xml | 19 +- app/src/main/res/values-kk-rKZ/strings.xml | 19 +- app/src/main/res/values-or-rIN/strings.xml | 19 +- app/src/main/res/values-pl-rPL/strings.xml | 19 +- app/src/main/res/values-pt-rBR/strings.xml | 19 +- app/src/main/res/values-pt-rPT/strings.xml | 19 +- app/src/main/res/values-ru-rRU/strings.xml | 19 +- app/src/main/res/values-sr/strings.xml | 19 +- app/src/main/res/values-tr-rTR/strings.xml | 19 +- app/src/main/res/values-uk-rUA/strings.xml | 19 +- app/src/main/res/values/strings.xml | 19 +- 37 files changed, 797 insertions(+), 144 deletions(-) rename app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/{RoomStateDialog.kt => ModActionsDialog.kt} (54%) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsViewModel.kt 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 4a5f69ef0..3298c2584 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 @@ -262,6 +262,13 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au contentType(ContentType.Application.Json) } + suspend fun getShieldMode(broadcasterUserId: UserId, moderatorUserId: UserId): HttpResponse? = ktorClient.get("moderation/shield_mode") { + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null bearerAuth(oAuth) 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 3c4a53190..e1193af91 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 @@ -242,6 +242,14 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { .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() 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 ac2da4c95..8f66811a1 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 @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable enum class InputAction { - Search, LastMessage, Stream, RoomState, Fullscreen, HideInput, Debug + Search, LastMessage, Stream, ModActions, Fullscreen, HideInput, Debug } @Serializable @@ -21,7 +21,7 @@ data class AppearanceSettings( val showChangelogs: Boolean = true, val showCharacterCounter: Boolean = false, val inputActions: List = listOf( - InputAction.Stream, InputAction.RoomState, + InputAction.Stream, InputAction.ModActions, InputAction.Search, InputAction.LastMessage, ), ) 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 index 9909b9d50..28fd79903 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -129,7 +129,6 @@ sealed interface ToolbarAction { data object ChooseMedia : ToolbarAction data object ReloadEmotes : ToolbarAction data object Reconnect : ToolbarAction - data object ClearChat : ToolbarAction data object OpenSettings : ToolbarAction } 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 index 253346352..1d2e04f8a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -183,7 +183,6 @@ fun InlineOverflowMenu( if (isLoggedIn) { InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { onAction(ToolbarAction.BlockChannel); onDismiss() } } - InlineMenuItem(text = stringResource(R.string.clear_chat), icon = Icons.Default.DeleteSweep) { onAction(ToolbarAction.ClearChat); onDismiss() } } } } 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 index 10ba5cede..eee569ed1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -338,7 +338,8 @@ fun MainScreen( dialogViewModel = dialogViewModel, isLoggedIn = isLoggedIn, activeChannel = activeChannel, - roomStateChannel = inputState.activeChannel, + modActionsChannel = inputState.activeChannel, + isStreamActive = currentStream != null, inputSheetState = inputSheetState, snackbarHostState = snackbarHostState, onAddChannel = { @@ -576,7 +577,7 @@ fun MainScreen( val totalMenuHeight = targetMenuHeight + navBarHeightDp // Shared scaffold bottom padding calculation - val hasDialogWithInput = dialogState.showAddChannel || dialogState.showRoomState || dialogState.showManageChannels || dialogState.showNewWhisper + 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) @@ -626,7 +627,7 @@ fun MainScreen( else -> activeChannel?.let { streamViewModel.toggleStream(it) } } }, - onChangeRoomState = dialogViewModel::showRoomState, + onModActions = dialogViewModel::showModActions, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, debugMode = mainState.debugMode, @@ -709,7 +710,6 @@ fun MainScreen( onReconnect() } - ToolbarAction.ClearChat -> dialogViewModel.showClearChat() ToolbarAction.OpenSettings -> onNavigateToSettings() } } 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 index e5cad70e2..4fc4cd161 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -181,9 +181,9 @@ private fun getOverflowItem( else -> null } - InputAction.RoomState -> when { + InputAction.ModActions -> when { isModerator -> OverflowItem( - labelRes = R.string.menu_room_state, + labelRes = R.string.menu_mod_actions, icon = Icons.Default.Shield, ) @@ -209,7 +209,7 @@ private fun getOverflowItem( 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.RoomState -> inputEnabled + InputAction.Stream, InputAction.ModActions -> inputEnabled } /** 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 index ecf3b0f73..af5c807d2 100644 --- 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 @@ -55,20 +55,12 @@ class DialogStateViewModel( update { copy(showBlockChannel = false) } } - fun showClearChat() { - update { copy(showClearChat = true) } + fun showModActions() { + update { copy(showModActions = true) } } - fun dismissClearChat() { - update { copy(showClearChat = false) } - } - - fun showRoomState() { - update { copy(showRoomState = true) } - } - - fun dismissRoomState() { - update { copy(showRoomState = false) } + fun dismissModActions() { + update { copy(showModActions = false) } } // Auth dialogs @@ -151,8 +143,7 @@ data class DialogState( val showManageChannels: Boolean = false, val showRemoveChannel: Boolean = false, val showBlockChannel: Boolean = false, - val showClearChat: Boolean = false, - val showRoomState: Boolean = false, + val showModActions: Boolean = false, val showLogout: Boolean = false, val loginOutdated: UserName? = null, val showLoginExpired: Boolean = false, 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 index 5bed67154..db2965167 100644 --- 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 @@ -44,7 +44,8 @@ fun MainScreenDialogs( dialogViewModel: DialogStateViewModel, isLoggedIn: Boolean, activeChannel: UserName?, - roomStateChannel: UserName?, + modActionsChannel: UserName?, + isStreamActive: Boolean, inputSheetState: InputSheetState, snackbarHostState: SnackbarHostState, onAddChannel: (UserName) -> Unit, @@ -83,13 +84,24 @@ fun MainScreenDialogs( ) } - if (dialogState.showRoomState && roomStateChannel != null) { - RoomStateDialog( - roomState = channelRepository.getRoomState(roomStateChannel), + if (dialogState.showModActions && modActionsChannel != null) { + val preferenceStore: DankChatPreferenceStore = koinInject() + val roomState = channelRepository.getRoomState(modActionsChannel) + val isBroadcaster = preferenceStore.userIdString == roomState?.channelId + val modActionsViewModel: ModActionsViewModel = koinViewModel( + key = modActionsChannel.value, + parameters = { parametersOf(modActionsChannel) } + ) + val shieldModeActive by modActionsViewModel.shieldModeActive.collectAsStateWithLifecycle() + ModActionsDialog( + roomState = roomState, + isBroadcaster = isBroadcaster, + isStreamActive = isStreamActive, + shieldModeActive = shieldModeActive, onSendCommand = { command -> chatInputViewModel.trySendMessageOrCommand(command) }, - onDismiss = dialogViewModel::dismissRoomState + onDismiss = dialogViewModel::dismissModActions ) } @@ -117,18 +129,6 @@ fun MainScreenDialogs( ) } - if (dialogState.showClearChat && activeChannel != null) { - ConfirmationDialog( - title = stringResource(R.string.confirm_clear_chat_question), - confirmText = stringResource(R.string.dialog_ok), - onConfirm = { - channelManagementViewModel.clearChat(activeChannel) - dialogViewModel.dismissClearChat() - }, - onDismiss = dialogViewModel::dismissClearChat, - ) - } - if (dialogState.showLogout) { ConfirmationDialog( title = stringResource(R.string.confirm_logout_question), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/RoomStateDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsDialog.kt similarity index 54% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/RoomStateDialog.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsDialog.kt index 723b76eca..b254a9989 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/RoomStateDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsDialog.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +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 @@ -20,6 +22,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip 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 @@ -34,6 +37,7 @@ 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.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R @@ -42,12 +46,17 @@ import com.flxrs.dankchat.utils.DateTimeUtils private data class ParameterDialogConfig(val titleRes: Int, val hintRes: Int, val defaultValue: String, val commandPrefix: String) -private enum class ParameterDialogType { - SLOW_MODE, - FOLLOWER_MODE +private sealed interface SubView { + data object SlowMode : SubView + data object FollowerMode : SubView + data object CommercialPresets : SubView + data object AnnounceInput : SubView + data object RaidInput : SubView + data object ShoutoutInput : 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) @@ -74,30 +83,58 @@ private fun formatFollowerPreset(minutes: Int): String = when (minutes) { @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -fun RoomStateDialog( +fun ModActionsDialog( roomState: RoomState?, + isBroadcaster: Boolean, + isStreamActive: Boolean, + shieldModeActive: Boolean?, onSendCommand: (String) -> Unit, onDismiss: () -> Unit, ) { - var presetsView by remember { mutableStateOf(null) } - var parameterDialog by remember { mutableStateOf(null) } + var subView by remember { mutableStateOf(null) } + var parameterDialog by remember { mutableStateOf(null) } var showSheet by remember { mutableStateOf(true) } + var showClearChatConfirmation by remember { mutableStateOf(false) } + + if (showClearChatConfirmation) { + AlertDialog( + onDismissRequest = { showClearChatConfirmation = false }, + title = { Text(stringResource(R.string.mod_actions_clear_chat)) }, + text = { Text(stringResource(R.string.mod_actions_confirm_clear_chat)) }, + confirmButton = { + TextButton(onClick = { + onSendCommand("/clear") + showClearChatConfirmation = false + onDismiss() + }) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { showClearChatConfirmation = false }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } parameterDialog?.let { type -> val (titleRes, hintRes, defaultValue, commandPrefix) = when (type) { - ParameterDialogType.SLOW_MODE -> ParameterDialogConfig( + SubView.SlowMode -> ParameterDialogConfig( titleRes = R.string.room_state_slow_mode, hintRes = R.string.seconds, defaultValue = "30", commandPrefix = "/slow" ) - ParameterDialogType.FOLLOWER_MODE -> ParameterDialogConfig( + SubView.FollowerMode -> ParameterDialogConfig( titleRes = R.string.room_state_follower_only, hintRes = R.string.minutes, defaultValue = "10", commandPrefix = "/followers" ) + + else -> return@let } var inputValue by remember(type) { mutableStateOf(defaultValue) } @@ -144,24 +181,28 @@ fun RoomStateDialog( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { AnimatedContent( - targetState = presetsView, + targetState = subView, transitionSpec = { when { targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() } }, - label = "RoomStateContent" + label = "ModActionsContent" ) { currentView -> when (currentView) { - null -> RoomStateModeChips( + null -> ModActionsMainView( roomState = roomState, + isBroadcaster = isBroadcaster, + isStreamActive = isStreamActive, + shieldModeActive = shieldModeActive, onSendCommand = onSendCommand, - onShowPresets = { presetsView = it }, + onShowSubView = { subView = it }, + onClearChat = { showClearChatConfirmation = true }, onDismiss = onDismiss, ) - ParameterDialogType.SLOW_MODE -> PresetChips( + SubView.SlowMode -> PresetChips( titleRes = R.string.room_state_slow_mode, presets = SLOW_MODE_PRESETS, formatLabel = { stringResource(R.string.room_state_duration_seconds, it) }, @@ -170,21 +211,59 @@ fun RoomStateDialog( onDismiss() }, onCustomClick = { - parameterDialog = ParameterDialogType.SLOW_MODE + parameterDialog = SubView.SlowMode showSheet = false }, ) - ParameterDialogType.FOLLOWER_MODE -> FollowerPresetChips( + SubView.FollowerMode -> FollowerPresetChips( onPresetClick = { preset -> onSendCommand("/followers ${preset.commandArg}") onDismiss() }, onCustomClick = { - parameterDialog = ParameterDialogType.FOLLOWER_MODE + parameterDialog = SubView.FollowerMode showSheet = false }, ) + + 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.AnnounceInput -> UserInputSubView( + titleRes = R.string.mod_actions_announce, + hintRes = R.string.mod_actions_announce_hint, + onConfirm = { message -> + onSendCommand("/announce $message") + onDismiss() + }, + ) + + SubView.RaidInput -> UserInputSubView( + titleRes = R.string.mod_actions_raid, + hintRes = R.string.mod_actions_channel_hint, + onConfirm = { target -> + onSendCommand("/raid $target") + onDismiss() + }, + ) + + SubView.ShoutoutInput -> UserInputSubView( + titleRes = R.string.mod_actions_shoutout, + hintRes = R.string.mod_actions_username_hint, + onConfirm = { target -> + onSendCommand("/shoutout $target") + onDismiss() + }, + ) } } } @@ -193,18 +272,125 @@ fun RoomStateDialog( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun RoomStateModeChips( +private fun ModActionsMainView( roomState: RoomState?, + isBroadcaster: Boolean, + isStreamActive: Boolean, + shieldModeActive: Boolean?, onSendCommand: (String) -> Unit, - onShowPresets: (ParameterDialogType) -> Unit, + onShowSubView: (SubView) -> Unit, + onClearChat: () -> Unit, onDismiss: () -> Unit, ) { - FlowRow( + 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 = { + onSendCommand(if (isShieldActive) "/shieldoff" else "/shield") + onDismiss() + }, + 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 = { onShowSubView(SubView.AnnounceInput) }, + 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), ) { @@ -218,7 +404,9 @@ private fun RoomStateModeChips( label = { Text(stringResource(R.string.room_state_emote_only)) }, leadingIcon = if (isEmoteOnly) { { Icon(Icons.Default.Check, contentDescription = null) } - } else null, + } else { + null + }, ) val isSubOnly = roomState?.isSubscriberMode == true @@ -231,7 +419,9 @@ private fun RoomStateModeChips( label = { Text(stringResource(R.string.room_state_subscriber_only)) }, leadingIcon = if (isSubOnly) { { Icon(Icons.Default.Check, contentDescription = null) } - } else null, + } else { + null + }, ) val isSlowMode = roomState?.isSlowMode == true @@ -239,11 +429,13 @@ private fun RoomStateModeChips( FilterChip( selected = isSlowMode, onClick = { - if (isSlowMode) { - onSendCommand("/slowoff") - onDismiss() - } else { - onShowPresets(ParameterDialogType.SLOW_MODE) + when { + isSlowMode -> { + onSendCommand("/slowoff") + onDismiss() + } + + else -> onShowSubView(SubView.SlowMode) } }, label = { @@ -252,7 +444,9 @@ private fun RoomStateModeChips( }, leadingIcon = if (isSlowMode) { { Icon(Icons.Default.Check, contentDescription = null) } - } else null, + } else { + null + }, ) val isUniqueChat = roomState?.isUniqueChatMode == true @@ -265,7 +459,9 @@ private fun RoomStateModeChips( label = { Text(stringResource(R.string.room_state_unique_chat)) }, leadingIcon = if (isUniqueChat) { { Icon(Icons.Default.Check, contentDescription = null) } - } else null, + } else { + null + }, ) val isFollowerOnly = roomState?.isFollowMode == true @@ -273,11 +469,13 @@ private fun RoomStateModeChips( FilterChip( selected = isFollowerOnly, onClick = { - if (isFollowerOnly) { - onSendCommand("/followersoff") - onDismiss() - } else { - onShowPresets(ParameterDialogType.FOLLOWER_MODE) + when { + isFollowerOnly -> { + onSendCommand("/followersoff") + onDismiss() + } + + else -> onShowSubView(SubView.FollowerMode) } }, label = { @@ -286,7 +484,9 @@ private fun RoomStateModeChips( }, leadingIcon = if (isFollowerOnly) { { Icon(Icons.Default.Check, contentDescription = null) } - } else null, + } else { + null + }, ) } } @@ -298,7 +498,7 @@ private fun PresetChips( presets: List, formatLabel: @Composable (Int) -> String, onPresetClick: (Int) -> Unit, - onCustomClick: () -> Unit, + onCustomClick: (() -> Unit)?, ) { Column( modifier = Modifier @@ -325,10 +525,12 @@ private fun PresetChips( ) } - AssistChip( - onClick = onCustomClick, - label = { Text(stringResource(R.string.room_state_preset_custom)) }, - ) + if (onCustomClick != null) { + AssistChip( + onClick = onCustomClick, + label = { Text(stringResource(R.string.room_state_preset_custom)) }, + ) + } } } } @@ -371,3 +573,51 @@ private fun FollowerPresetChips( } } } + +@Composable +private fun UserInputSubView( + titleRes: Int, + hintRes: Int, + onConfirm: (String) -> Unit, +) { + var inputValue by remember { mutableStateOf("") } + + 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(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + if (inputValue.isNotBlank()) { + onConfirm(inputValue.trim()) + } + }), + modifier = Modifier.fillMaxWidth(), + ) + + TextButton( + onClick = { onConfirm(inputValue.trim()) }, + enabled = inputValue.isNotBlank(), + modifier = Modifier + .align(Alignment.End) + .padding(top = 8.dp), + ) { + Text(stringResource(R.string.dialog_ok)) + } + } +} 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..9d354b532 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsViewModel.kt @@ -0,0 +1,39 @@ +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.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 kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.InjectedParam + +@KoinViewModel +class ModActionsViewModel( + @InjectedParam private val channel: UserName, + private val helixApiClient: HelixApiClient, + private val authDataStore: AuthDataStore, + private val channelRepository: ChannelRepository, +) : ViewModel() { + + private val _shieldModeActive = MutableStateFlow(null) + val shieldModeActive: StateFlow = _shieldModeActive.asStateFlow() + + init { + fetchShieldMode() + } + + private fun fetchShieldMode() { + viewModelScope.launch { + val channelId = channelRepository.getChannel(channel)?.id ?: return@launch + val moderatorId = authDataStore.userIdString ?: return@launch + helixApiClient.getShieldMode(channelId, moderatorId) + .onSuccess { _shieldModeActive.value = it.isActive } + } + } +} 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 index 164e7c842..ba830d392 100644 --- 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 @@ -50,7 +50,7 @@ fun ChatBottomBar( onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, onToggleStream: () -> Unit, - onChangeRoomState: () -> Unit, + onModActions: () -> Unit, onSearchClick: () -> Unit, onDebugInfoClick: () -> Unit = {}, debugMode: Boolean = false, @@ -103,7 +103,7 @@ fun ChatBottomBar( onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, onToggleStream = onToggleStream, - onChangeRoomState = onChangeRoomState, + onModActions = onModActions, onInputActionsChanged = onInputActionsChanged, onSearchClick = onSearchClick, onDebugInfoClick = onDebugInfoClick, 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 index 40f780a2e..ca78e2fd3 100644 --- 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 @@ -147,7 +147,7 @@ fun ChatInputLayout( showWhisperOverlay: Boolean, whisperTarget: UserName?, onWhisperDismiss: () -> Unit, - onChangeRoomState: () -> Unit, + onModActions: () -> Unit, onInputActionsChanged: (ImmutableList) -> Unit, onSearchClick: () -> Unit = {}, onDebugInfoClick: () -> Unit = {}, @@ -190,7 +190,7 @@ fun ChatInputLayout( inputActions.filter { action -> when (action) { InputAction.Stream -> hasStreamData || isStreamActive - InputAction.RoomState -> isModerator + InputAction.ModActions -> isModerator InputAction.Debug -> debugMode else -> true } @@ -346,7 +346,7 @@ fun ChatInputLayout( onSearchClick = onSearchClick, onLastMessageClick = onLastMessageClick, onToggleStream = onToggleStream, - onChangeRoomState = onChangeRoomState, + onModActions = onModActions, onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, onDebugInfoClick = onDebugInfoClick, @@ -415,7 +415,7 @@ fun ChatInputLayout( InputAction.Search -> onSearchClick() InputAction.LastMessage -> onLastMessageClick() InputAction.Stream -> onToggleStream() - InputAction.RoomState -> onChangeRoomState() + InputAction.ModActions -> onModActions() InputAction.Fullscreen -> onToggleFullscreen() InputAction.HideInput -> onToggleInput() InputAction.Debug -> onDebugInfoClick() @@ -581,7 +581,7 @@ private val InputAction.labelRes: Int InputAction.Search -> R.string.input_action_search InputAction.LastMessage -> R.string.input_action_last_message InputAction.Stream -> R.string.input_action_stream - InputAction.RoomState -> R.string.input_action_room_state + 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 @@ -592,7 +592,7 @@ private val InputAction.icon: ImageVector InputAction.Search -> Icons.Default.Search InputAction.LastMessage -> Icons.Default.History InputAction.Stream -> Icons.Default.Videocam - InputAction.RoomState -> Icons.Default.Shield + InputAction.ModActions -> Icons.Default.Shield InputAction.Fullscreen -> Icons.Default.Fullscreen InputAction.HideInput -> Icons.Default.VisibilityOff InputAction.Debug -> Icons.Default.BugReport @@ -662,7 +662,7 @@ private fun InputActionButton( onSearchClick: () -> Unit, onLastMessageClick: () -> Unit, onToggleStream: () -> Unit, - onChangeRoomState: () -> Unit, + onModActions: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, onDebugInfoClick: () -> Unit = {}, @@ -677,7 +677,7 @@ private fun InputActionButton( onToggleStream, ) - InputAction.RoomState -> Triple(Icons.Default.Shield, R.string.menu_room_state, onChangeRoomState) + InputAction.ModActions -> Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) InputAction.Fullscreen -> Triple( if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, R.string.toggle_fullscreen, @@ -691,7 +691,7 @@ private fun InputActionButton( val actionEnabled = when (action) { InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true InputAction.LastMessage -> enabled && hasLastMessage - InputAction.Stream, InputAction.RoomState -> enabled + InputAction.Stream, InputAction.ModActions -> enabled } IconButton( @@ -763,7 +763,7 @@ private fun InputActionsRow( onSearchClick: () -> Unit, onLastMessageClick: () -> Unit, onToggleStream: () -> Unit, - onChangeRoomState: () -> Unit, + onModActions: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, onDebugInfoClick: () -> Unit = {}, @@ -835,7 +835,7 @@ private fun InputActionsRow( onSearchClick = onSearchClick, onLastMessageClick = onLastMessageClick, onToggleStream = onToggleStream, - onChangeRoomState = onChangeRoomState, + onModActions = onModActions, onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, onDebugInfoClick = onDebugInfoClick, @@ -867,7 +867,7 @@ private fun EndAlignedActionGroup( onSearchClick: () -> Unit, onLastMessageClick: () -> Unit, onToggleStream: () -> Unit, - onChangeRoomState: () -> Unit, + onModActions: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, onDebugInfoClick: () -> Unit = {}, @@ -929,7 +929,7 @@ private fun EndAlignedActionGroup( onSearchClick = onSearchClick, onLastMessageClick = onLastMessageClick, onToggleStream = onToggleStream, - onChangeRoomState = onChangeRoomState, + onModActions = onModActions, onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, onDebugInfoClick = onDebugInfoClick, 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 7d6415ca2..a1d389558 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 @@ -261,14 +261,14 @@ 全螢幕 退出全螢幕 隱藏輸入框 - 頻道設定 + 頻道設定 最多 %1$d 個動作 搜尋訊息 上一則訊息 切換實況 - 頻道設定 + 頻道設定 全螢幕 隱藏輸入框 設定動作 @@ -285,6 +285,21 @@ %1$d天 %1$d週 %1$d月 + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel 帳戶 重新登入 登出 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index d4915a246..8dc13f014 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -597,6 +597,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Дадайце канал, каб пачаць размову @@ -608,13 +623,13 @@ На ўвесь экран Выйсці з поўнаэкраннага рэжыму Схаваць увод - Налады канала + Налады канала Пошук паведамленняў Апошняе паведамленне Пераключыць трансляцыю - Налады канала + Налады канала На ўвесь экран Схаваць увод Наладзіць дзеянні diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 742b94e84..9134a00d6 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -532,6 +532,21 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Afegeix un canal per començar a xatejar @@ -542,13 +557,13 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Pantalla completa Surt de la pantalla completa Amaga l\'entrada - Configuració del canal + Configuració del canal Cerca missatges Últim missatge Commuta l\'emissió - Configuració del canal + Configuració del canal Pantalla completa Amaga l\'entrada Configura accions diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8a9b532ba..ece6edf97 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -598,6 +598,21 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Přidejte kanál a začněte chatovat @@ -609,13 +624,13 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Celá obrazovka Ukončit celou obrazovku Skrýt vstup - Nastavení kanálu + Nastavení kanálu Hledat zprávy Poslední zpráva Přepnout stream - Nastavení kanálu + Nastavení kanálu Celá obrazovka Skrýt vstup Konfigurovat akce diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index fc03e4442..077386699 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -598,6 +598,21 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Füge einen Kanal hinzu, um zu chatten @@ -609,13 +624,13 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Vollbild Vollbild beenden Eingabe ausblenden - Kanaleinstellungen + Kanaleinstellungen Nachrichten durchsuchen Letzte Nachricht Stream umschalten - Kanaleinstellungen + Kanaleinstellungen Vollbild Eingabe ausblenden Aktionen konfigurieren diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index b04dea274..4b0d0a13d 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -410,6 +410,21 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Add a channel to start chatting No recent emotes Show stream @@ -417,11 +432,11 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Exit fullscreen Hide input - Channel settings + Channel settings Search messages Last message Toggle stream - Channel settings + Channel settings Fullscreen Hide input Configure actions diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 85f19a52e..7c28db365 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -411,6 +411,21 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Add a channel to start chatting No recent emotes Show stream @@ -418,11 +433,11 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Exit fullscreen Hide input - Channel settings + Channel settings Search messages Last message Toggle stream - Channel settings + Channel settings Fullscreen Hide input Configure actions diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4103dfea2..0ec8cd2f9 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -592,6 +592,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Add a channel to start chatting @@ -603,13 +618,13 @@ Fullscreen Exit fullscreen Hide input - Channel settings + Channel settings Search messages Last message Toggle stream - Channel settings + Channel settings Fullscreen Hide input Configure actions diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 49d7c7e7e..e8ba5464f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -607,6 +607,21 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Añade un canal para empezar a chatear @@ -618,13 +633,13 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Pantalla completa Salir de pantalla completa Ocultar entrada - Ajustes del canal + Ajustes del canal Buscar mensajes Último mensaje Alternar stream - Ajustes del canal + Ajustes del canal Pantalla completa Ocultar entrada Configurar acciones diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 6ccf235a8..65002318d 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -589,6 +589,21 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Lisää kanava aloittaaksesi keskustelun @@ -600,13 +615,13 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Koko näyttö Poistu koko näytöstä Piilota syöttö - Kanavan asetukset + Kanavan asetukset Hae viestejä Viimeisin viesti Vaihda lähetys - Kanavan asetukset + Kanavan asetukset Koko näyttö Piilota syöttö Muokkaa toimintoja diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b6c1ced50..923863987 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -591,6 +591,21 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Ajoutez une chaîne pour commencer à discuter @@ -602,13 +617,13 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Plein écran Quitter le plein écran Masquer la saisie - Paramètres de la chaîne + Paramètres de la chaîne Rechercher des messages Dernier message Basculer le stream - Paramètres de la chaîne + Paramètres de la chaîne Plein écran Masquer la saisie Configurer les actions diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index b82de63a0..18e164cad 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -576,6 +576,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Adj hozzá egy csatornát a csevegéshez @@ -587,13 +602,13 @@ Teljes képernyő Kilépés a teljes képernyőből Bevitel elrejtése - Csatornabeállítások + Csatornabeállítások Üzenetek keresése Utolsó üzenet Közvetítés váltása - Csatornabeállítások + Csatornabeállítások Teljes képernyő Bevitel elrejtése Műveletek beállítása diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2b176659b..2a25e634e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -574,6 +574,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Aggiungi un canale per iniziare a chattare @@ -585,13 +600,13 @@ Schermo intero Esci dallo schermo intero Nascondi input - Impostazioni canale + Impostazioni canale Cerca messaggi Ultimo messaggio Attiva/disattiva stream - Impostazioni canale + Impostazioni canale Schermo intero Nascondi input Configura azioni diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 1f3e0faec..236b6a1eb 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -557,6 +557,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel チャンネルを追加してチャットを始めましょう @@ -568,13 +583,13 @@ 全画面 全画面を終了 入力欄を非表示 - チャンネル設定 + チャンネル設定 メッセージを検索 最後のメッセージ 配信を切り替え - チャンネル設定 + チャンネル設定 全画面 入力欄を非表示 アクションを設定 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index af8432fa7..818cf7e99 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -259,7 +259,7 @@ Толық экран Толық экраннан шығу Енгізуді жасыру - Арна параметрлері + Арна параметрлері Ең көбі %1$d әрекет Ең көбі %1$d әрекет @@ -267,7 +267,7 @@ Хабарларды іздеу Соңғы хабарлама Ағынды қосу/өшіру - Арна параметрлері + Арна параметрлері Толық экран Енгізуді жасыру Әрекеттерді баптау @@ -284,6 +284,21 @@ %1$dк %1$dа %1$dай + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Тіркелгі Қайта кіру Шығу diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 740531a59..7b10a0c84 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -259,7 +259,7 @@ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ବାହାରନ୍ତୁ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ - ଚ୍ୟାନେଲ ସେଟିଂସ୍ + ଚ୍ୟାନେଲ ସେଟିଂସ୍ ସର୍ବାଧିକ %1$d କ୍ରିୟା ସର୍ବାଧିକ %1$d କ୍ରିୟା @@ -267,7 +267,7 @@ ବାର୍ତ୍ତା ଖୋଜନ୍ତୁ ଶେଷ ବାର୍ତ୍ତା ଷ୍ଟ୍ରିମ୍ ଟୋଗଲ୍ କରନ୍ତୁ - ଚ୍ୟାନେଲ ସେଟିଂସ୍ + ଚ୍ୟାନେଲ ସେଟିଂସ୍ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ କ୍ରିୟାଗୁଡିକ ବିନ୍ୟାସ କରନ୍ତୁ @@ -284,6 +284,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel ଆକାଉଣ୍ଟ୍ ପୁନର୍ବାର ଲଗ୍ ଇନ୍ କରନ୍ତୁ | ପ୍ରସ୍ଥାନ କର diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 4582ec6e2..2b25aabb4 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -616,6 +616,21 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Dodaj kanał, aby zacząć czatować @@ -627,13 +642,13 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pełny ekran Wyjdź z pełnego ekranu Ukryj pole wpisywania - Ustawienia kanału + Ustawienia kanału Szukaj wiadomości Ostatnia wiadomość Przełącz transmisję - Ustawienia kanału + Ustawienia kanału Pełny ekran Ukryj pole wpisywania Konfiguruj akcje diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 798afe7b9..d5400a1b9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -586,6 +586,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Adicione um canal para começar a conversar @@ -597,13 +612,13 @@ Tela cheia Sair da tela cheia Ocultar entrada - Configurações do canal + Configurações do canal Pesquisar mensagens Última mensagem Alternar stream - Configurações do canal + Configurações do canal Tela cheia Ocultar entrada Configurar ações diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index c72e11458..8aec6e8d7 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -576,6 +576,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Adicione um canal para começar a conversar @@ -587,13 +602,13 @@ Ecrã inteiro Sair do ecrã inteiro Ocultar entrada - Definições do canal + Definições do canal Pesquisar mensagens Última mensagem Alternar transmissão - Definições do canal + Definições do canal Ecrã inteiro Ocultar entrada Configurar ações diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 37ebfdcc0..a492d963b 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -602,6 +602,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Добавьте канал, чтобы начать общение @@ -613,13 +628,13 @@ На весь экран Выйти из полноэкранного режима Скрыть ввод - Настройки канала + Настройки канала Поиск сообщений Последнее сообщение Переключить трансляцию - Настройки канала + Настройки канала На весь экран Скрыть ввод Настроить действия diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 4811dcfad..a0f278552 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -597,6 +597,21 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Додајте канал да бисте почели да ћаскате @@ -607,13 +622,13 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Цео екран Изађи из целог екрана Сакриј унос - Подешавања канала + Подешавања канала Претражи поруке Последња порука Укључи/искључи стрим - Подешавања канала + Подешавања канала Цео екран Сакриј унос Подеси акције diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 8a4ea1822..e7b20cdd2 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -597,6 +597,21 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Sohbete başlamak için bir kanal ekleyin @@ -608,13 +623,13 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Tam ekran Tam ekrandan çık Girişi gizle - Kanal ayarları + Kanal ayarları Mesajları ara Son mesaj Yayını aç/kapat - Kanal ayarları + Kanal ayarları Tam ekran Girişi gizle Eylemleri yapılandır diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 6c883498e..5f2e9bcb8 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -599,6 +599,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Додайте канал, щоб почати спілкування @@ -610,13 +625,13 @@ На весь екран Вийти з повноекранного режиму Сховати введення - Налаштування каналу + Налаштування каналу Пошук повідомлень Останнє повідомлення Перемкнути трансляцію - Налаштування каналу + Налаштування каналу На весь екран Сховати введення Налаштувати дії diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ffae05d8..e95329f10 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,7 +276,7 @@ Fullscreen Exit fullscreen Hide input - Channel settings + Mod actions Maximum of %1$d action Maximum of %1$d actions @@ -284,7 +284,7 @@ Search messages Last message Toggle stream - Channel settings + Mod actions Fullscreen Hide input Debug @@ -302,6 +302,21 @@ %1$dd %1$dw %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Announce + Message + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Account Login again Logout From 05b27e47edd4ad2c0798534b8dfd2ba100b34dd1 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 23:27:01 +0100 Subject: [PATCH 130/349] fix(viewmodel): Prefix keyed ViewModel keys to prevent ViewModelStore collisions --- .../com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt | 2 +- .../com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index db2965167..a5362ec18 100644 --- 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 @@ -89,7 +89,7 @@ fun MainScreenDialogs( val roomState = channelRepository.getRoomState(modActionsChannel) val isBroadcaster = preferenceStore.userIdString == roomState?.channelId val modActionsViewModel: ModActionsViewModel = koinViewModel( - key = modActionsChannel.value, + key = "mod-actions-${modActionsChannel.value}", parameters = { parametersOf(modActionsChannel) } ) val shieldModeActive by modActionsViewModel.shieldModeActive.collectAsStateWithLifecycle() 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 index 71aef37d6..789c038bb 100644 --- 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 @@ -163,7 +163,7 @@ fun FullScreenSheetOverlay( is FullScreenSheetState.History -> { val viewModel: MessageHistoryViewModel = koinViewModel( - key = sheetState.channel.value, + key = "history-${sheetState.channel.value}", parameters = { parametersOf(sheetState.channel) }, ) val historyClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, isLongPress -> From 9c195b56f4cd1c25da2e4d1975410e8cf8e883db Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 27 Mar 2026 23:52:05 +0100 Subject: [PATCH 131/349] feat(input): Add announce mode with input overlay, unify overlay types into sealed interface, clear modes on channel switch --- .../com/flxrs/dankchat/ui/main/InputState.kt | 1 + .../com/flxrs/dankchat/ui/main/MainScreen.kt | 13 +++- .../ui/main/dialog/MainScreenDialogs.kt | 1 + .../ui/main/dialog/ModActionsDialog.kt | 18 +++--- .../dankchat/ui/main/input/ChatBottomBar.kt | 11 +--- .../dankchat/ui/main/input/ChatInputLayout.kt | 35 ++++------- .../ui/main/input/ChatInputViewModel.kt | 59 ++++++++++++++----- .../main/res/values-b+zh+Hant+TW/strings.xml | 4 +- app/src/main/res/values-be-rBY/strings.xml | 4 +- app/src/main/res/values-ca/strings.xml | 4 +- app/src/main/res/values-cs/strings.xml | 4 +- app/src/main/res/values-de-rDE/strings.xml | 4 +- app/src/main/res/values-en-rAU/strings.xml | 4 +- app/src/main/res/values-en-rGB/strings.xml | 4 +- app/src/main/res/values-en/strings.xml | 4 +- app/src/main/res/values-es-rES/strings.xml | 4 +- app/src/main/res/values-fi-rFI/strings.xml | 4 +- app/src/main/res/values-fr-rFR/strings.xml | 4 +- app/src/main/res/values-hu-rHU/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values-ja-rJP/strings.xml | 4 +- app/src/main/res/values-kk-rKZ/strings.xml | 4 +- app/src/main/res/values-or-rIN/strings.xml | 4 +- app/src/main/res/values-pl-rPL/strings.xml | 4 +- app/src/main/res/values-pt-rBR/strings.xml | 4 +- app/src/main/res/values-pt-rPT/strings.xml | 4 +- app/src/main/res/values-ru-rRU/strings.xml | 4 +- app/src/main/res/values-sr/strings.xml | 4 +- app/src/main/res/values-tr-rTR/strings.xml | 4 +- app/src/main/res/values-uk-rUA/strings.xml | 4 +- app/src/main/res/values/strings.xml | 3 +- 31 files changed, 127 insertions(+), 106 deletions(-) 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 index 24e6cbb07..4e5d0d75f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.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/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt index eee569ed1..a00c5fb43 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -118,6 +118,7 @@ import com.flxrs.dankchat.ui.main.dialog.DialogStateViewModel import com.flxrs.dankchat.ui.main.dialog.MainScreenDialogs import com.flxrs.dankchat.ui.main.input.CharacterCounterState import com.flxrs.dankchat.ui.main.input.ChatBottomBar +import com.flxrs.dankchat.ui.main.input.InputOverlay import com.flxrs.dankchat.ui.main.input.ChatInputViewModel import com.flxrs.dankchat.ui.main.input.SuggestionDropdown import com.flxrs.dankchat.ui.main.input.TourOverlayState @@ -599,7 +600,7 @@ fun MainScreen( is FullScreenSheetState.Replies -> persistentListOf(InputAction.LastMessage) is FullScreenSheetState.Whisper, is FullScreenSheetState.Mention -> when { - inputState.isWhisperTabActive && inputState.whisperTarget != null -> persistentListOf(InputAction.LastMessage) + inputState.isWhisperTabActive && inputState.overlay is InputOverlay.Whisper -> persistentListOf(InputAction.LastMessage) else -> persistentListOf() } @@ -617,8 +618,14 @@ fun MainScreen( keyboardController?.show() } }, - onWhisperDismiss = { chatInputViewModel.setWhisperTarget(null) }, - onReplyDismiss = { chatInputViewModel.setReplying(false) }, + 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, onToggleStream = { 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 index a5362ec18..2679d9a60 100644 --- 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 @@ -101,6 +101,7 @@ fun MainScreenDialogs( onSendCommand = { command -> chatInputViewModel.trySendMessageOrCommand(command) }, + onAnnounce = { chatInputViewModel.setAnnouncing(true) }, onDismiss = dialogViewModel::dismissModActions ) } 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 index b254a9989..52242ed16 100644 --- 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 @@ -50,7 +50,6 @@ private sealed interface SubView { data object SlowMode : SubView data object FollowerMode : SubView data object CommercialPresets : SubView - data object AnnounceInput : SubView data object RaidInput : SubView data object ShoutoutInput : SubView } @@ -89,6 +88,7 @@ fun ModActionsDialog( isStreamActive: Boolean, shieldModeActive: Boolean?, onSendCommand: (String) -> Unit, + onAnnounce: () -> Unit, onDismiss: () -> Unit, ) { var subView by remember { mutableStateOf(null) } @@ -199,6 +199,7 @@ fun ModActionsDialog( onSendCommand = onSendCommand, onShowSubView = { subView = it }, onClearChat = { showClearChatConfirmation = true }, + onAnnounce = onAnnounce, onDismiss = onDismiss, ) @@ -238,15 +239,6 @@ fun ModActionsDialog( onCustomClick = null, ) - SubView.AnnounceInput -> UserInputSubView( - titleRes = R.string.mod_actions_announce, - hintRes = R.string.mod_actions_announce_hint, - onConfirm = { message -> - onSendCommand("/announce $message") - onDismiss() - }, - ) - SubView.RaidInput -> UserInputSubView( titleRes = R.string.mod_actions_raid, hintRes = R.string.mod_actions_channel_hint, @@ -280,6 +272,7 @@ private fun ModActionsMainView( onSendCommand: (String) -> Unit, onShowSubView: (SubView) -> Unit, onClearChat: () -> Unit, + onAnnounce: () -> Unit, onDismiss: () -> Unit, ) { Column( @@ -325,7 +318,10 @@ private fun ModActionsMainView( label = { Text(stringResource(R.string.mod_actions_clear_chat)) }, ) AssistChip( - onClick = { onShowSubView(SubView.AnnounceInput) }, + onClick = { + onAnnounce() + onDismiss() + }, label = { Text(stringResource(R.string.mod_actions_announce)) }, ) if (isStreamActive) { 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 index ba830d392..e30dd3ae3 100644 --- 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 @@ -45,8 +45,7 @@ fun ChatBottomBar( onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, - onWhisperDismiss: () -> Unit, - onReplyDismiss: () -> Unit, + onOverlayDismiss: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, onToggleStream: () -> Unit, @@ -81,8 +80,6 @@ fun ChatBottomBar( enabled = inputState.enabled, hasLastMessage = inputState.hasLastMessage, canSend = inputState.canSend, - showReplyOverlay = inputState.showReplyOverlay, - replyName = inputState.replyName, isEmoteMenuOpen = inputState.isEmoteMenuOpen, helperText = if (isSheetOpen) null else inputState.helperText, isUploading = isUploading, @@ -96,10 +93,8 @@ fun ChatBottomBar( onSend = onSend, onLastMessageClick = onLastMessageClick, onEmoteClick = onEmoteClick, - showWhisperOverlay = inputState.showWhisperOverlay, - whisperTarget = inputState.whisperTarget, - onWhisperDismiss = onWhisperDismiss, - onReplyDismiss = onReplyDismiss, + overlay = inputState.overlay, + onOverlayDismiss = onOverlayDismiss, onToggleFullscreen = onToggleFullscreen, onToggleInput = onToggleInput, onToggleStream = onToggleStream, 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 index ca78e2fd3..be2f60a7a 100644 --- 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 @@ -124,8 +124,6 @@ fun ChatInputLayout( enabled: Boolean, hasLastMessage: Boolean, canSend: Boolean, - showReplyOverlay: Boolean, - replyName: UserName?, isEmoteMenuOpen: Boolean, helperText: String?, isUploading: Boolean, @@ -140,13 +138,11 @@ fun ChatInputLayout( onSend: () -> Unit, onLastMessageClick: () -> Unit, onEmoteClick: () -> Unit, - onReplyDismiss: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, onToggleStream: () -> Unit, - showWhisperOverlay: Boolean, - whisperTarget: UserName?, - onWhisperDismiss: () -> Unit, + overlay: InputOverlay, + onOverlayDismiss: () -> Unit, onModActions: () -> Unit, onInputActionsChanged: (ImmutableList) -> Unit, onSearchClick: () -> Unit = {}, @@ -164,6 +160,7 @@ fun ChatInputLayout( 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) @@ -216,27 +213,21 @@ fun ChatInputLayout( .fillMaxWidth() .navigationBarsPadding() ) { - // Reply Header + // Input mode overlay header AnimatedVisibility( - visible = showReplyOverlay && replyName != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - InputOverlayHeader( - text = stringResource(R.string.reply_header, replyName?.value.orEmpty()), - onDismiss = onReplyDismiss, - ) - } - - // Whisper Header - AnimatedVisibility( - visible = showWhisperOverlay && whisperTarget != null, + 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 -> "" + } InputOverlayHeader( - text = stringResource(R.string.whisper_header, whisperTarget?.value.orEmpty()), - onDismiss = onWhisperDismiss, + text = headerText, + onDismiss = onOverlayDismiss, ) } 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 index 30889251c..fb043e642 100644 --- 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 @@ -96,6 +96,8 @@ class ChatInputViewModel( private var lastWhisperText: String? = null val whisperTarget: StateFlow = _whisperTarget.asStateFlow() + private val _isAnnouncing = MutableStateFlow(false) + // Create flow from TextFieldState tracking both text and cursor position private val codePointCount = snapshotFlow { val text = textFieldState.text @@ -161,6 +163,8 @@ class ChatInputViewModel( viewModelScope.launch { chatChannelProvider.activeChannel.collect { repeatedSend.update { it.copy(enabled = false) } + setReplying(false) + _isAnnouncing.value = false } } @@ -208,7 +212,8 @@ class ChatInputViewModel( val replyName: UserName?, val replyMessageId: String?, val isEmoteMenuOpen: Boolean, - val whisperTarget: UserName? + val whisperTarget: UserName?, + val isAnnouncing: Boolean, ) fun uiState(externalSheetState: StateFlow, externalMentionTab: StateFlow): StateFlow { @@ -250,10 +255,18 @@ class ChatInputViewModel( externalMentionTab, replyStateFlow, _isEmoteMenuOpen, - _whisperTarget - ) { sheetState, tab, replyState, isEmoteMenuOpen, whisperTarget -> + _whisperTarget, + _isAnnouncing, + ) { values -> + val sheetState = values[0] as FullScreenSheetState + val tab = values[1] as Int + @Suppress("UNCHECKED_CAST") + val replyState = values[2] as Triple + val isEmoteMenuOpen = values[3] as Boolean + val whisperTarget = values[4] as UserName? + val isAnnouncing = values[5] as Boolean val (isReplying, replyName, replyMessageId) = replyState - InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget) + InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget, isAnnouncing) } return combine( @@ -262,7 +275,7 @@ class ChatInputViewModel( helperText, codePointCount, chatSettingsDataStore.userLongClickBehavior, - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget), helperText, codePoints, userLongClickBehavior -> + ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget, isAnnouncing), helperText, codePoints, userLongClickBehavior -> val isMentionsTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 0 val isWhisperTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 1 val isInReplyThread = sheetState is FullScreenSheetState.Replies @@ -273,6 +286,7 @@ class ChatInputViewModel( ConnectionState.CONNECTED -> when { isWhisperTabActive && whisperTarget != null -> InputState.Whispering effectiveIsReplying -> InputState.Replying + isAnnouncing -> InputState.Announcing else -> InputState.Default } @@ -288,9 +302,13 @@ class ChatInputViewModel( val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn && enabled - val showReplyOverlay = isReplying && !isInReplyThread - val showWhisperOverlay = isWhisperTabActive && whisperTarget != null val effectiveReplyName = replyName ?: (sheetState as? FullScreenSheetState.Replies)?.replyName + val overlay = when { + isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName) + isWhisperTabActive && whisperTarget != null -> InputOverlay.Whisper(whisperTarget) + isAnnouncing -> InputOverlay.Announce + else -> InputOverlay.None + } ChatInputUiState( text = text, @@ -305,13 +323,10 @@ class ChatInputViewModel( connectionState = connectionState, isLoggedIn = isLoggedIn, inputState = inputState, - showReplyOverlay = showReplyOverlay, + overlay = overlay, replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, - replyName = effectiveReplyName, isEmoteMenuOpen = isEmoteMenuOpen, helperText = helperText, - showWhisperOverlay = showWhisperOverlay, - whisperTarget = whisperTarget, isWhisperTabActive = isWhisperTabActive, characterCounter = CharacterCounterState.Visible( text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", @@ -326,11 +341,16 @@ class ChatInputViewModel( 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 + } trySendMessageOrCommand(messageToSend) textFieldState.clearText() } @@ -421,6 +441,10 @@ class ChatInputViewModel( _replyName.value = replyName } + fun setAnnouncing(announcing: Boolean) { + _isAnnouncing.value = announcing + } + fun setWhisperTarget(target: UserName?) { _whisperTarget.value = target if (target == null) { @@ -544,18 +568,23 @@ data class ChatInputUiState( val connectionState: ConnectionState = ConnectionState.DISCONNECTED, val isLoggedIn: Boolean = false, val inputState: InputState = InputState.Disconnected, - val showReplyOverlay: Boolean = false, + val overlay: InputOverlay = InputOverlay.None, val replyMessageId: String? = null, - val replyName: UserName? = null, val isEmoteMenuOpen: Boolean = false, val helperText: String? = null, - val showWhisperOverlay: Boolean = false, - val whisperTarget: UserName? = null, val isWhisperTabActive: Boolean = false, val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, val userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, ) +@Stable +sealed interface InputOverlay { + data object None : InputOverlay + data class Reply(val name: UserName) : InputOverlay + data class Whisper(val target: UserName) : InputOverlay + data object Announce : InputOverlay +} + @Stable sealed interface CharacterCounterState { data object Hidden : CharacterCounterState 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 a1d389558..49ca57c76 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 @@ -53,7 +53,7 @@ 已斷線 尚未登入 回覆 - 您有新的提及訊息 + Send announcement 您有新的提及訊息 %1$s 剛於 #%2$s 提及了您 您剛在 #%1$s 中被提及 已登入為 %1$s @@ -292,7 +292,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 8dc13f014..7e9118a8b 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -48,7 +48,7 @@ Адключана Вы не ўвайшлі Адказаць - У вас новыя згадванні + Send announcement У вас новыя згадванні %1$s згадаў вас у #%2$s Вас згадалі ў #%1$s Вы ўвайшлі як %1$s @@ -604,7 +604,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9134a00d6..e30cc7390 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -51,7 +51,7 @@ Desconnectat Sessió no iniciada Resposta - Tens noves mencions + 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 @@ -539,7 +539,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ece6edf97..587f8dd1e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -48,7 +48,7 @@ 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 @@ -605,7 +605,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 077386699..29d2c7b55 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -48,7 +48,7 @@ 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 @@ -605,7 +605,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 4b0d0a13d..98f1de13d 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -417,7 +417,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid @@ -467,7 +467,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Close the emote menu Emotes Reply - Data loading failed with multiple errors:\n%1$s + 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) diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 7c28db365..440d817ec 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -418,7 +418,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid @@ -468,7 +468,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Close the emote menu Emotes Reply - Data loading failed with multiple errors:\n%1$s + 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) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0ec8cd2f9..2fcf8c13f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -48,7 +48,7 @@ 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 @@ -599,7 +599,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index e8ba5464f..f46b1a832 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 @@ -614,7 +614,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 65002318d..ad951ab32 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -50,7 +50,7 @@ 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 @@ -596,7 +596,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 923863987..5da58eeeb 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -48,7 +48,7 @@ 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 @@ -598,7 +598,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 18e164cad..e722a5806 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -48,7 +48,7 @@ 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 @@ -583,7 +583,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2a25e634e..b75415477 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -47,7 +47,7 @@ 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 @@ -581,7 +581,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 236b6a1eb..2f9e8774e 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -47,7 +47,7 @@ 切断されました ログインされていません 返信 - 新しいメンションがあります + Send announcement 新しいメンションがあります %1$sが#%2$sであなたに返信しました #%1$s 内であなた宛の返信があります %1$sとしてログイン @@ -564,7 +564,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 818cf7e99..576f5d1ed 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -53,7 +53,7 @@ Ажыратты Жүйеге кірмеген Жауап беру - Сізде жаңа ескертулер бар + Send announcement Сізде жаңа ескертулер бар %1$s сізді #%2$s ішінде атап өтті Сіз #%1$s ішінде аталды %1$s ретінде кіру @@ -291,7 +291,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 7b10a0c84..202184511 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -53,7 +53,7 @@ ବିଚ୍ଛିନ୍ନ ହୋଇଛି | ଲଗ୍ ଇନ୍ ହୋଇନାହିଁ | ପ୍ରତ୍ୟୁତ୍ତର - ତୁମର ନୂତନ ଉଲ୍ଲେଖ ଅଛି | + Send announcement ତୁମର ନୂତନ ଉଲ୍ଲେଖ ଅଛି | %1$s ତୁମକୁ କେବଳ ଉଲ୍ଲେଖ କରିଛି | #%2$s ଆପଣ ଏଥିରେ ଉଲ୍ଲେଖ କରିଛନ୍ତି | #%1$s ଯେପରି ଲଗ୍ ଇନ୍ କରୁଛି | %1$s @@ -291,7 +291,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 2b25aabb4..de3e06af7 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 @@ -623,7 +623,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d5400a1b9..66eecbb5b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -48,7 +48,7 @@ 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 @@ -593,7 +593,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 8aec6e8d7..cf446f675 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -48,7 +48,7 @@ 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 @@ -583,7 +583,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index a492d963b..23a506781 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -48,7 +48,7 @@ Отключено Вы не вошли Ответить - У вас новые упоминания + Send announcement У вас новые упоминания %1$s упомянул вас в #%2$s Вас упомянули в #%1$s Вы вошли как %1$s @@ -609,7 +609,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index a0f278552..ccf55a25e 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -52,7 +52,7 @@ 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 @@ -604,7 +604,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e7b20cdd2..8db081091 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ı @@ -604,7 +604,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 5f2e9bcb8..becf021d1 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -48,7 +48,7 @@ Від\'єднано Ви не ввійшли Відповісти - У Вас є нові згадки + Send announcement У Вас є нові згадки %1$s згадав Вас у #%2$s Вас згадали у #%1$s Ви увійшли під ім\'ям %1$s @@ -606,7 +606,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e95329f10..67dffad25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,6 +53,7 @@ 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 @@ -309,7 +310,7 @@ Clear chat Clear all messages in this channel? Announce - Message + Announcement Shoutout Commercial Raid From 211430f95a8d1217d8fe184e0f3b0a8e25e7aa8c Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 09:30:49 +0100 Subject: [PATCH 132/349] refactor(dialogs): Convert input dialogs to bottom sheets, inline channel rename, add nested scroll fix for manage channels --- .../ui/main/dialog/AddChannelDialog.kt | 63 +++-- .../ui/main/dialog/EditChannelDialog.kt | 97 -------- .../ui/main/dialog/ManageChannelsDialog.kt | 232 +++++++++++++----- .../ui/main/dialog/ModActionsDialog.kt | 111 +++------ 4 files changed, 258 insertions(+), 245 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EditChannelDialog.kt 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 index 3d2b438f0..f46039c5b 100644 --- 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 @@ -1,25 +1,38 @@ package com.flxrs.dankchat.ui.main.dialog +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue 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.runtime.snapshotFlow +import kotlinx.coroutines.flow.first +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.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddChannelDialog( onDismiss: () -> Unit, @@ -27,11 +40,32 @@ fun AddChannelDialog( ) { var channelName by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - AlertDialog( + LaunchedEffect(Unit) { + snapshotFlow { sheetState.currentValue } + .first { it == SheetValue.Expanded } + focusRequester.requestFocus() + } + + ModalBottomSheet( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.add_channel)) }, - text = { + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + ) { + Text( + text = stringResource(R.string.add_channel), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + OutlinedTextField( value = channelName, onValueChange = { channelName = it }, @@ -45,28 +79,23 @@ fun AddChannelDialog( onDismiss() } }), - modifier = Modifier.focusRequester(focusRequester) + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), ) - }, - confirmButton = { + TextButton( onClick = { onAddChannel(UserName(channelName.trim())) onDismiss() }, - enabled = channelName.isNotBlank() + enabled = channelName.isNotBlank(), + modifier = Modifier + .align(Alignment.End) + .padding(top = 8.dp), ) { Text(stringResource(R.string.dialog_ok)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dialog_cancel)) - } } - ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EditChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EditChannelDialog.kt deleted file mode 100644 index d513ee639..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EditChannelDialog.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.flxrs.dankchat.ui.main.dialog - -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -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.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.painterResource -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.TextFieldValue -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.preferences.model.ChannelWithRename - -@Composable -fun EditChannelDialog( - channelWithRename: ChannelWithRename, - onRename: (UserName, String?) -> Unit, - onDismiss: () -> Unit, -) { - var renameText by remember { - val initialText = channelWithRename.rename?.value ?: "" - mutableStateOf( - TextFieldValue( - text = initialText, - selection = TextRange(initialText.length) - ) - ) - } - val focusRequester = remember { FocusRequester() } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.edit_dialog_title)) }, - text = { - OutlinedTextField( - value = renameText, - onValueChange = { renameText = it }, - label = { Text(channelWithRename.channel.value) }, - placeholder = { Text(channelWithRename.channel.value) }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { - onRename(channelWithRename.channel, renameText.text) - onDismiss() - }), - trailingIcon = if (renameText.text.isNotEmpty()) { - { - IconButton(onClick = { - onRename(channelWithRename.channel, null) - onDismiss() - }) { - Icon( - painter = painterResource(R.drawable.ic_clear), - contentDescription = stringResource(R.string.clear) - ) - } - } - } else null, - modifier = Modifier.focusRequester(focusRequester) - ) - }, - confirmButton = { - TextButton( - onClick = { - onRename(channelWithRename.channel, renameText.text) - onDismiss() - } - ) { - Text(stringResource(R.string.dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } -} 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 index bf3798c80..5728cd089 100644 --- 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 @@ -1,14 +1,26 @@ package com.flxrs.dankchat.ui.main.dialog +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.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api @@ -17,8 +29,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.OutlinedTextField 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 @@ -28,17 +43,24 @@ 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.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 @@ -51,7 +73,7 @@ fun ManageChannelsDialog( onDismiss: () -> Unit, ) { var channelToDelete by remember { mutableStateOf(null) } - var channelToEdit by remember { mutableStateOf(null) } + var editingChannel by remember { mutableStateOf(null) } // Local state for smooth reordering and deferred updates val localChannels = remember { mutableStateListOf() } @@ -75,12 +97,16 @@ fun ManageChannelsDialog( onApplyChanges(localChannels.toList()) onDismiss() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + contentWindowInsets = { WindowInsets.statusBars }, ) { + val navBarPadding = WindowInsets.navigationBars.asPaddingValues() LazyColumn( modifier = Modifier .fillMaxWidth() - .padding(bottom = 32.dp), - state = lazyListState + .nestedScroll(BottomSheetNestedScrollConnection), + state = lazyListState, + contentPadding = navBarPadding, ) { itemsIndexed(localChannels, key = { _, it -> it.channel.value }) { index, channelWithRename -> ReorderableItem(reorderableState, key = channelWithRename.channel.value) { isDragging -> @@ -88,11 +114,15 @@ fun ManageChannelsDialog( Surface( shadowElevation = elevation, - color = if (isDragging) MaterialTheme.colorScheme.surfaceContainerHighest else Color.Transparent + 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 */ } @@ -102,7 +132,17 @@ fun ManageChannelsDialog( onChannelSelected(channelWithRename.channel) onDismiss() }, - onEdit = { channelToEdit = channelWithRename }, + 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) { @@ -142,78 +182,150 @@ fun ManageChannelsDialog( onDismiss = { channelToDelete = null }, ) } - - channelToEdit?.let { channel -> - EditChannelDialog( - channelWithRename = channel, - onRename = { userName, newName -> - val index = localChannels.indexOfFirst { it.channel == userName } - if (index != -1) { - val rename = newName?.ifBlank { null }?.let { UserName(it) } - localChannels[index] = localChannels[index].copy(rename = rename) - } - }, - onDismiss = { channelToEdit = null } - ) - } } @Composable private fun ChannelItem( channelWithRename: ChannelWithRename, - modifier: Modifier = Modifier, // This modifier will carry the drag handle semantics + isEditing: Boolean, + modifier: Modifier = Modifier, onNavigate: () -> Unit, onEdit: () -> Unit, - onDelete: () -> Unit + onRename: (String?) -> Unit, + onDelete: () -> Unit, ) { - 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) - ) + Column { + 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) + 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) + }, + 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) + ) + } } - IconButton(onClick = onEdit) { - Icon( - painter = painterResource(R.drawable.ic_edit), - contentDescription = stringResource(R.string.edit_dialog_title) + AnimatedVisibility( + visible = isEditing, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + modifier = Modifier.imePadding(), + ) { + InlineRenameField( + channelWithRename = channelWithRename, + onRename = onRename, ) } + } +} - IconButton(onClick = onDelete) { - Icon( - painter = painterResource(R.drawable.ic_delete_outline), - contentDescription = stringResource(R.string.remove_channel) +@Composable +private fun InlineRenameField( + channelWithRename: ChannelWithRename, + onRename: (String?) -> Unit, +) { + val initialText = channelWithRename.rename?.value ?: "" + 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)) + } } } } 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 index 52242ed16..281f06f5f 100644 --- 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 @@ -31,6 +31,9 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,11 +47,11 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.data.twitch.message.RoomState import com.flxrs.dankchat.utils.DateTimeUtils -private data class ParameterDialogConfig(val titleRes: Int, val hintRes: Int, val defaultValue: String, val commandPrefix: String) - 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 @@ -92,8 +95,6 @@ fun ModActionsDialog( onDismiss: () -> Unit, ) { var subView by remember { mutableStateOf(null) } - var parameterDialog by remember { mutableStateOf(null) } - var showSheet by remember { mutableStateOf(true) } var showClearChatConfirmation by remember { mutableStateOf(false) } if (showClearChatConfirmation) { @@ -118,64 +119,7 @@ fun ModActionsDialog( ) } - parameterDialog?.let { type -> - val (titleRes, hintRes, defaultValue, commandPrefix) = when (type) { - SubView.SlowMode -> ParameterDialogConfig( - titleRes = R.string.room_state_slow_mode, - hintRes = R.string.seconds, - defaultValue = "30", - commandPrefix = "/slow" - ) - - SubView.FollowerMode -> ParameterDialogConfig( - titleRes = R.string.room_state_follower_only, - hintRes = R.string.minutes, - defaultValue = "10", - commandPrefix = "/followers" - ) - - else -> return@let - } - - var inputValue by remember(type) { mutableStateOf(defaultValue) } - - AlertDialog( - onDismissRequest = { - parameterDialog = null - onDismiss() - }, - title = { Text(stringResource(titleRes)) }, - text = { - OutlinedTextField( - value = inputValue, - onValueChange = { inputValue = it }, - label = { Text(stringResource(hintRes)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton(onClick = { - onSendCommand("$commandPrefix $inputValue") - parameterDialog = null - onDismiss() - }) { - Text(stringResource(R.string.dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = { - parameterDialog = null - onDismiss() - }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - if (showSheet) { + run { ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -211,9 +155,17 @@ fun ModActionsDialog( onSendCommand("/slow $value") onDismiss() }, - onCustomClick = { - parameterDialog = SubView.SlowMode - showSheet = false + 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() }, ) @@ -222,9 +174,17 @@ fun ModActionsDialog( onSendCommand("/followers ${preset.commandArg}") onDismiss() }, - onCustomClick = { - parameterDialog = SubView.FollowerMode - showSheet = false + 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() }, ) @@ -574,9 +534,16 @@ private fun FollowerPresetChips( private fun UserInputSubView( titleRes: Int, hintRes: Int, + defaultValue: String = "", + keyboardType: KeyboardType = KeyboardType.Text, onConfirm: (String) -> Unit, ) { - var inputValue by remember { mutableStateOf("") } + var inputValue by remember { mutableStateOf(defaultValue) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } Column( modifier = Modifier @@ -597,13 +564,15 @@ private fun UserInputSubView( onValueChange = { inputValue = it }, label = { Text(stringResource(hintRes)) }, singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { if (inputValue.isNotBlank()) { onConfirm(inputValue.trim()) } }), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), ) TextButton( From 85f11249c8001bbe00c7380749dc6217bcf7b79d Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 10:24:12 +0100 Subject: [PATCH 133/349] refactor(sheets): Extract StyledBottomSheet and InputBottomSheet components using compose-unstyled, migrate AddChannelDialog, remove unused onOpenChannel param --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 1 - .../ui/main/dialog/AddChannelDialog.kt | 98 ++--------------- .../ui/main/dialog/MainScreenDialogs.kt | 1 - .../utils/compose/InputBottomSheet.kt | 82 ++++++++++++++ .../utils/compose/StyledBottomSheet.kt | 102 ++++++++++++++++++ 5 files changed, 194 insertions(+), 90 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt 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 index a00c5fb43..e61e1169e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -349,7 +349,6 @@ fun MainScreen( }, onLogout = onLogout, onLogin = onLogin, - onOpenChannel = onOpenChannel, onReportChannel = onReportChannel, onOpenUrl = onOpenUrl, onJumpToMessage = { messageId, channel -> 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 index f46039c5b..04f430fc3 100644 --- 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 @@ -1,101 +1,23 @@ package com.flxrs.dankchat.ui.main.dialog -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetValue -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.runtime.snapshotFlow -import kotlinx.coroutines.flow.first -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.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.utils.compose.InputBottomSheet -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddChannelDialog( onDismiss: () -> Unit, onAddChannel: (UserName) -> Unit, ) { - var channelName by remember { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - LaunchedEffect(Unit) { - snapshotFlow { sheetState.currentValue } - .first { it == SheetValue.Expanded } - focusRequester.requestFocus() - } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), - ) { - Text( - text = stringResource(R.string.add_channel), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp), - ) - - OutlinedTextField( - value = channelName, - onValueChange = { channelName = it }, - label = { Text(stringResource(R.string.add_channel_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { - val trimmed = channelName.trim() - if (trimmed.isNotBlank()) { - onAddChannel(UserName(trimmed)) - onDismiss() - } - }), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - ) - - TextButton( - onClick = { - onAddChannel(UserName(channelName.trim())) - onDismiss() - }, - enabled = channelName.isNotBlank(), - modifier = Modifier - .align(Alignment.End) - .padding(top = 8.dp), - ) { - Text(stringResource(R.string.dialog_ok)) - } - } - } + InputBottomSheet( + title = stringResource(R.string.add_channel), + hint = stringResource(R.string.add_channel_hint), + onConfirm = { name -> + onAddChannel(UserName(name)) + onDismiss() + }, + onDismiss = onDismiss, + ) } 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 index 2679d9a60..caec1ab04 100644 --- 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 @@ -51,7 +51,6 @@ fun MainScreenDialogs( onAddChannel: (UserName) -> Unit, onLogout: () -> Unit, onLogin: () -> Unit, - onOpenChannel: () -> Unit, onReportChannel: () -> Unit, onOpenUrl: (String) -> Unit, onJumpToMessage: (messageId: String, channel: UserName) -> Unit = { _, _ -> }, 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..469c43a8d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt @@ -0,0 +1,82 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.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.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +@Composable +fun InputBottomSheet( + title: String, + hint: String, + confirmText: String = stringResource(R.string.dialog_ok), + defaultValue: String = "", + keyboardType: KeyboardType = KeyboardType.Text, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var inputValue by remember { mutableStateOf(defaultValue) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + StyledBottomSheet(onDismiss = onDismiss) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + + OutlinedTextField( + value = inputValue, + onValueChange = { inputValue = it }, + label = { Text(hint) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { + val trimmed = inputValue.trim() + if (trimmed.isNotBlank()) { + onConfirm(trimmed) + } + }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + + TextButton( + onClick = { onConfirm(inputValue.trim()) }, + enabled = inputValue.isNotBlank(), + modifier = Modifier + .align(Alignment.End) + .padding(top = 8.dp), + ) { + Text(confirmText) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt new file mode 100644 index 000000000..fd00f0033 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt @@ -0,0 +1,102 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.graphicsLayer +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 java.util.concurrent.CancellationException + +@Composable +fun StyledBottomSheet( + onDismiss: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + 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.1f) + Sheet( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .shadow(8.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding() + .imePadding(), + ) { + DragIndication( + modifier = Modifier + .padding(bottom = 12.dp) + .align(Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + RoundedCornerShape(50), + ) + .size(width = 32.dp, height = 4.dp), + ) + + content() + } + } + } +} From bfe012ab5a78addc1bd7fd8d5fcb747721eb73e9 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 10:42:18 +0100 Subject: [PATCH 134/349] feat(add-channel): Add duplicate channel validation with animated error, clear button, and channel check via ViewModel --- .../channel/ChannelManagementViewModel.kt | 4 ++ .../ui/main/dialog/AddChannelDialog.kt | 9 ++++ .../ui/main/dialog/MainScreenDialogs.kt | 3 +- .../utils/compose/InputBottomSheet.kt | 47 +++++++++++++++++-- .../utils/compose/StyledBottomSheet.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 6 files changed, 60 insertions(+), 6 deletions(-) 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 index 7960e3d78..6919b803e 100644 --- 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 @@ -63,6 +63,10 @@ class ChannelManagementViewModel( } } + fun isChannelAdded(name: String): Boolean { + return preferenceStore.channels.any { it.value.equals(name, ignoreCase = true) } + } + fun addChannel(channel: UserName) { val current = preferenceStore.channels if (channel !in current) { 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 index 04f430fc3..d76fbdb6f 100644 --- 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 @@ -10,10 +10,19 @@ import com.flxrs.dankchat.utils.compose.InputBottomSheet 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() 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 index caec1ab04..0491281fc 100644 --- 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 @@ -69,7 +69,8 @@ fun MainScreenDialogs( if (dialogState.showAddChannel) { AddChannelDialog( onDismiss = dialogViewModel::dismissAddChannel, - onAddChannel = onAddChannel + onAddChannel = onAddChannel, + isChannelAlreadyAdded = channelManagementViewModel::isChannelAdded, ) } 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 index 469c43a8d..86965b2d5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt @@ -1,7 +1,16 @@ package com.flxrs.dankchat.utils.compose +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.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme @@ -31,11 +40,16 @@ fun InputBottomSheet( confirmText: String = stringResource(R.string.dialog_ok), defaultValue: String = "", keyboardType: KeyboardType = KeyboardType.Text, + showClearButton: Boolean = false, + validate: ((String) -> String?)? = null, onConfirm: (String) -> Unit, onDismiss: () -> Unit, ) { var inputValue by remember { mutableStateOf(defaultValue) } val focusRequester = remember { FocusRequester() } + val trimmed = inputValue.trim() + val errorText = validate?.invoke(trimmed) + val isValid = trimmed.isNotBlank() && errorText == null LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -54,13 +68,25 @@ fun InputBottomSheet( onValueChange = { inputValue = it }, label = { Text(hint) }, singleLine = true, + isError = errorText != null, + trailingIcon = if (showClearButton && inputValue.isNotEmpty()) { + { + IconButton(onClick = { inputValue = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear), + ) + } + } + } else { + null + }, keyboardOptions = KeyboardOptions( keyboardType = keyboardType, imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions(onDone = { - val trimmed = inputValue.trim() - if (trimmed.isNotBlank()) { + if (isValid) { onConfirm(trimmed) } }), @@ -69,9 +95,22 @@ fun InputBottomSheet( .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), + ) + } + TextButton( - onClick = { onConfirm(inputValue.trim()) }, - enabled = inputValue.isNotBlank(), + onClick = { onConfirm(trimmed) }, + enabled = isValid, modifier = Modifier .align(Alignment.End) .padding(top = 8.dp), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt index fd00f0033..093f5e458 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt @@ -80,7 +80,7 @@ fun StyledBottomSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(horizontal = 16.dp) .navigationBarsPadding() .imePadding(), ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67dffad25..8df6292f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,6 +112,7 @@ Paste Channel name + Channel is already added Recent Backspace Subs From 2cb08ea180e968c1c74f83a53427822891c94cc8 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 13:39:44 +0100 Subject: [PATCH 135/349] feat(auth): Add startup validation gating, migrate auth dialogs to bottom sheets, split data loading into auth/non-auth phases --- .../com/flxrs/dankchat/DankChatViewModel.kt | 10 +- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 80 +++++----- .../data/auth/AuthStateCoordinator.kt | 33 +++- .../data/auth/StartupValidationHolder.kt | 43 ++++++ .../dankchat/data/debug/AuthDebugSection.kt | 9 +- .../flxrs/dankchat/data/debug/DebugSection.kt | 2 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 5 +- .../dankchat/domain/ChannelDataCoordinator.kt | 14 +- .../dankchat/domain/ChannelDataLoader.kt | 11 +- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 8 +- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 7 + .../com/flxrs/dankchat/ui/main/MainScreen.kt | 4 +- .../ui/main/MainScreenEventHandler.kt | 26 ++-- .../dankchat/ui/main/QuickActionsMenu.kt | 4 +- .../ui/main/dialog/DialogStateViewModel.kt | 19 --- .../ui/main/dialog/MainScreenDialogs.kt | 62 +++----- .../dankchat/ui/main/input/ChatInputLayout.kt | 12 +- .../dankchat/ui/main/sheet/DebugInfoSheet.kt | 19 ++- .../dankchat/ui/tour/FeatureTourViewModel.kt | 9 +- .../utils/compose/BottomSheetNestedScroll.kt | 2 +- .../dankchat/utils/compose/InfoBottomSheet.kt | 141 ++++++++++++++++++ 21 files changed, 384 insertions(+), 136 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 3d706a714..1b3e61804 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -3,6 +3,7 @@ package com.flxrs.dankchat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.AuthEvent import com.flxrs.dankchat.data.auth.AuthStateCoordinator import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider import com.flxrs.dankchat.data.repo.chat.ChatConnector @@ -47,7 +48,14 @@ class DankChatViewModel( init { viewModelScope.launch { - authStateCoordinator.validateOnStartup() + val result = authStateCoordinator.validateOnStartup() + when (result) { + // Don't connect with an invalid token — the logout/re-login flow + // triggers closeAndReconnect via the AuthStateCoordinator settings observer. + is AuthEvent.TokenInvalid -> return@launch + else -> Unit + } + initialConnectionStarted = true chatConnector.connectAndJoin(chatChannelProvider.channels.value.orEmpty()) } 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 3298c2584..302897ced 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 @@ -13,6 +13,7 @@ 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.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 @@ -27,10 +28,15 @@ import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType -class HelixApi(private val ktorClient: HttpClient, private val authDataStore: AuthDataStore) { +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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) logins.forEach { parameter("login", it) @@ -38,7 +44,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getUsersByIds(ids: List): HttpResponse? = ktorClient.get("users") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) ids.forEach { parameter("id", it) @@ -46,7 +52,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getChannelFollowers(broadcasterUserId: UserId, targetUserId: UserId? = null, first: Int? = null, after: String? = null): HttpResponse? = ktorClient.get("channels/followers") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) if (targetUserId != null) { @@ -61,7 +67,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getStreams(channels: List): HttpResponse? = ktorClient.get("streams") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) channels.forEach { parameter("user_login", it) @@ -69,7 +75,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getUserBlocks(userId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("users/blocks") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", userId) parameter("first", first) @@ -79,19 +85,19 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun putUserBlock(targetUserId: UserId): HttpResponse? = ktorClient.put("users/blocks") { - val oAuth = authDataStore.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 = authDataStore.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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -100,7 +106,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getModerators(broadcasterUserId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("moderation/moderators") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("first", first) @@ -110,21 +116,21 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun postModerator(broadcasterUserId: UserId, userId: UserId): HttpResponse? = ktorClient.post("moderation/moderators") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("from_user_id", fromUserId) parameter("to_user_id", toUserId) @@ -133,7 +139,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getVips(broadcasterUserId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("channels/vips") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("first", first) @@ -143,21 +149,21 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun postVip(broadcasterUserId: UserId, userId: UserId): HttpResponse? = ktorClient.post("channels/vips") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -166,7 +172,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun deleteBan(broadcasterUserId: UserId, moderatorUserId: UserId, targetUserId: UserId): HttpResponse? = ktorClient.delete("moderation/bans") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -174,7 +180,7 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun deleteMessages(broadcasterUserId: UserId, moderatorUserId: UserId, messageId: String?): HttpResponse? = ktorClient.delete("moderation/chat") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -184,41 +190,41 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun putUserChatColor(targetUserId: UserId, color: String): HttpResponse? = ktorClient.put("chat/color") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.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 = authDataStore.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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -227,34 +233,34 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getGlobalBadges(): HttpResponse? = ktorClient.get("chat/badges/global") { - val oAuth = authDataStore.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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) contentType(ContentType.Application.Json) } suspend fun getCheermotes(broadcasterId: UserId): HttpResponse? = ktorClient.get("bits/cheermotes") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("from_broadcaster_id", broadcasterUserId) parameter("to_broadcaster_id", targetUserId) @@ -263,14 +269,14 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getShieldMode(broadcasterUserId: UserId, moderatorUserId: UserId): HttpResponse? = ktorClient.get("moderation/shield_mode") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + 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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -279,20 +285,20 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun postEventSubSubscription(eventSubSubscriptionRequestDto: EventSubSubscriptionRequestDto): HttpResponse? = ktorClient.post("eventsub/subscriptions") { - val oAuth = authDataStore.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 = authDataStore.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 = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("user_id", userId) if (after != null) { @@ -301,13 +307,13 @@ class HelixApi(private val ktorClient: HttpClient, private val authDataStore: Au } suspend fun getChannelEmotes(broadcasterId: UserId): HttpResponse? = ktorClient.get("chat/emotes") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterId) } suspend fun postChatMessage(request: SendChatMessageRequestDto): HttpResponse? = ktorClient.post("chat/messages") { - val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) contentType(ContentType.Application.Json) setBody(request) 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 index ac80ca573..df6844908 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -43,6 +43,7 @@ class AuthStateCoordinator( private val ignoresRepository: IgnoresRepository, private val userStateRepository: UserStateRepository, private val emoteUsageRepository: EmoteUsageRepository, + private val startupValidationHolder: StartupValidationHolder, dispatchersProvider: DispatchersProvider, ) { @@ -61,6 +62,7 @@ class AuthStateCoordinator( .collect { settings -> when { settings.isLoggedIn -> { + startupValidationHolder.update(StartupValidation.Validated) chatConnector.reconnect() channelDataCoordinator.reloadGlobalData() settings.userName?.let { name -> @@ -79,10 +81,13 @@ class AuthStateCoordinator( } } - suspend fun validateOnStartup() { - if (!authDataStore.isLoggedIn) return + suspend fun validateOnStartup(): AuthEvent? { + if (!authDataStore.isLoggedIn) { + startupValidationHolder.update(StartupValidation.Validated) + return null + } - val token = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return + val token = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null val result = authApiClient.validateUser(token).fold( onSuccess = { validateDto -> // Update username from validation response @@ -95,7 +100,6 @@ class AuthStateCoordinator( onFailure = { throwable -> when { throwable is ApiException && throwable.status == HttpStatusCode.Unauthorized -> { - authDataStore.clearLogin() AuthEvent.TokenInvalid } @@ -106,7 +110,26 @@ class AuthStateCoordinator( } } ) - _events.send(result) + + 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() { 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..9873444ec --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt @@ -0,0 +1,43 @@ +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 awaitValidated() { + _state.first { it is StartupValidation.Validated } + } + + suspend fun awaitResolved() { + _state.first { it !is StartupValidation.Pending } + } +} 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 index 228013e1a..a8e0fd1eb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt @@ -1,6 +1,7 @@ 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 @@ -15,11 +16,17 @@ class AuthDebugSection( override fun entries(): Flow { return 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"), + 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/DebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt index 890e2e132..b512e671a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt @@ -10,4 +10,4 @@ interface DebugSection { data class DebugSectionSnapshot(val title: String, val entries: List) -data class DebugEntry(val label: String, val value: String) +data class DebugEntry(val label: String, val value: String, val copyValue: String? = null) 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 0027467ba..ca53ea548 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -13,6 +13,7 @@ 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.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.StartupValidationHolder import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp @@ -117,7 +118,7 @@ class NetworkModule { }) @Single - fun provideHelixApi(ktorClient: HttpClient, authDataStore: AuthDataStore, helixApiStats: HelixApiStats) = HelixApi(ktorClient.config { + fun provideHelixApi(ktorClient: HttpClient, authDataStore: AuthDataStore, helixApiStats: HelixApiStats, startupValidationHolder: StartupValidationHolder) = HelixApi(ktorClient.config { defaultRequest { url(HELIX_BASE_URL) header("Client-ID", authDataStore.clientId) @@ -127,7 +128,7 @@ class NetworkModule { helixApiStats.recordResponse(response.status.value) } } - }, authDataStore) + }, authDataStore, startupValidationHolder) @Single fun provideBadgesApi(ktorClient: HttpClient) = BadgesApi(ktorClient.config { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index c9eb2e3ff..cfcd83e29 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -2,6 +2,7 @@ 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 @@ -34,6 +35,7 @@ class ChannelDataCoordinator( private val dataRepository: DataRepository, private val authDataStore: AuthDataStore, private val preferenceStore: DankChatPreferenceStore, + private val startupValidationHolder: StartupValidationHolder, dispatchersProvider: DispatchersProvider ) { @@ -97,6 +99,7 @@ class ChannelDataCoordinator( } private suspend fun loadChannelDataSuspend(channel: UserName) { + startupValidationHolder.awaitResolved() val stateFlow = channelStates.getOrPut(channel) { MutableStateFlow(ChannelLoadingState.Idle) } @@ -113,13 +116,16 @@ class ChannelDataCoordinator( _globalLoadingState.value = GlobalLoadingState.Loading dataRepository.clearDataLoadingFailures() + // Phase 1: Non-auth data (3rd-party emotes, DankChat badges) — loads immediately globalDataLoader.loadGlobalData() - - // Reparse after global emotes load so 3rd party globals are visible immediately chatMessageRepository.reparseAllEmotesAndBadges() - // Load user emotes if logged in — only block on first page, rest loads async - if (authDataStore.isLoggedIn) { + // Phase 2: Auth-gated data (badges, user emotes, blocks) — wait for validation to resolve + startupValidationHolder.awaitResolved() + if (startupValidationHolder.isAuthAvailable && authDataStore.isLoggedIn) { + globalDataLoader.loadAuthGlobalData() + chatMessageRepository.reparseAllEmotesAndBadges() + val userId = authDataStore.userIdString if (userId != null) { val firstPageLoaded = CompletableDeferred() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index c1c5926ca..304b757da 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -28,17 +28,18 @@ class ChannelDataLoader( 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()) } - dataRepository.createFlowsIfNecessary(listOf(channel)) - chatRepository.createFlowsIfNecessary(channel) - - chatRepository.loadRecentMessagesIfEnabled(channel) - val failures = withContext(dispatchersProvider.io) { val badgesResult = async { loadChannelBadges(channel, channelInfo.id) } val emotesResults = async { loadChannelEmotes(channel, channelInfo) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 1a0d43f10..a2863bbcd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -23,12 +23,18 @@ class GlobalDataLoader( suspend fun loadGlobalData(): List> = withContext(dispatchersProvider.io) { val results = awaitAll( async { loadDankChatBadges() }, - async { loadGlobalBadges() }, async { loadGlobalBTTVEmotes() }, async { loadGlobalFFZEmotes() }, async { loadGlobalSevenTVEmotes() }, ) launch { loadSupibotCommands() } + results + } + + suspend fun loadAuthGlobalData(): List> = withContext(dispatchersProvider.io) { + val results = awaitAll( + async { loadGlobalBadges() }, + ) launch { loadUserBlocks() } results } 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 index 28fd79903..31a242292 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -564,7 +564,14 @@ fun FloatingToolbar( 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)) { 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 index e61e1169e..212074de6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -649,13 +649,15 @@ fun MainScreen( instantHide = isHistorySheet, isRepeatedSendEnabled = mainState.isRepeatedSendEnabled, onRepeatedSendChanged = chatInputViewModel::setRepeatedSend, - tourState = remember(featureTourState.currentTourStep, featureTourState.forceOverflowOpen) { + 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, ) 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 index ed584cf75..f43669497 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -18,6 +18,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.data.auth.AuthEvent import com.flxrs.dankchat.data.auth.AuthStateCoordinator +import com.flxrs.dankchat.data.auth.StartupValidation +import com.flxrs.dankchat.data.auth.StartupValidationHolder import com.flxrs.dankchat.data.repo.chat.toDisplayStrings import com.flxrs.dankchat.data.repo.data.toDisplayStrings import com.flxrs.dankchat.data.state.GlobalLoadingState @@ -83,15 +85,14 @@ fun MainScreenEventHandler( } } - // Collect auth events from AuthStateCoordinator - // Only process when RESUMED to avoid showing dialogs while another screen is on top. - // Events are buffered in the Channel and consumed once MainScreen becomes visible again. + // 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 -> { + is AuthEvent.LoggedIn -> { launch { delay(2000) snackbarHostState.currentSnackbarData?.dismiss() @@ -102,27 +103,24 @@ fun MainScreenEventHandler( ) } - is AuthEvent.ScopesOutdated -> { - dialogViewModel.showLoginOutdated(event.userName) - } - - AuthEvent.TokenInvalid -> { - dialogViewModel.showLoginExpired() - } - - AuthEvent.ValidationFailed -> { + AuthEvent.ValidationFailed -> { snackbarHostState.showSnackbar( message = resources.getString(R.string.oauth_verify_failed), duration = SnackbarDuration.Short, ) } + + else -> Unit } } } } + val startupValidationHolder: StartupValidationHolder = koinInject() + val startupValidation by startupValidationHolder.state.collectAsStateWithLifecycle() val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() - LaunchedEffect(loadingState) { + LaunchedEffect(loadingState, startupValidation) { + if (startupValidation !is StartupValidation.Validated) return@LaunchedEffect val state = loadingState as? GlobalLoadingState.Failed ?: return@LaunchedEffect val dataSteps = state.failures.map { it.step }.toDisplayStrings(resources) val chatSteps = state.chatFailures.map { it.step }.toDisplayStrings(resources) 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 index 4fc4cd161..c31300ee2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -222,7 +222,7 @@ private fun EndCaretTourTooltip( onAction: () -> Unit, onSkip: () -> Unit, ) { - val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + val containerColor = MaterialTheme.colorScheme.secondaryContainer Row(verticalAlignment = Alignment.CenterVertically) { Surface( shape = RoundedCornerShape(12.dp), @@ -239,7 +239,7 @@ private fun EndCaretTourTooltip( Text( text = text, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSecondaryContainer, ) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), 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 index af5c807d2..195ff11a1 100644 --- 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 @@ -2,7 +2,6 @@ package com.flxrs.dankchat.ui.main.dialog import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams @@ -72,22 +71,6 @@ class DialogStateViewModel( update { copy(showLogout = false) } } - fun showLoginOutdated(username: UserName) { - update { copy(loginOutdated = username) } - } - - fun dismissLoginOutdated() { - update { copy(loginOutdated = null) } - } - - fun showLoginExpired() { - update { copy(showLoginExpired = true) } - } - - fun dismissLoginExpired() { - update { copy(showLoginExpired = false) } - } - // Whisper dialog fun showNewWhisper() { update { copy(showNewWhisper = true) } @@ -145,8 +128,6 @@ data class DialogState( val showBlockChannel: Boolean = false, val showModActions: Boolean = false, val showLogout: Boolean = false, - val loginOutdated: UserName? = null, - val showLoginExpired: Boolean = false, val showNewWhisper: Boolean = false, val pendingUploadAction: (() -> Unit)? = null, val isUploading: Boolean = false, 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 index 0491281fc..d1ef31b8b 100644 --- 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 @@ -1,11 +1,8 @@ package com.flxrs.dankchat.ui.main.dialog import android.content.ClipData -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,9 +14,12 @@ 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.channel.ChannelRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.utils.compose.InfoBottomSheet import com.flxrs.dankchat.ui.chat.emote.EmoteInfoViewModel import com.flxrs.dankchat.ui.chat.message.MessageOptionsState import com.flxrs.dankchat.ui.chat.message.MessageOptionsViewModel @@ -65,6 +65,8 @@ fun MainScreenDialogs( val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() val channelRepository: ChannelRepository = koinInject() + val startupValidationHolder: StartupValidationHolder = koinInject() + val startupValidation by startupValidationHolder.state.collectAsStateWithLifecycle() if (dialogState.showAddChannel) { AddChannelDialog( @@ -142,45 +144,29 @@ fun MainScreenDialogs( ) } - if (dialogState.loginOutdated != null) { - AlertDialog( - onDismissRequest = dialogViewModel::dismissLoginOutdated, - title = { Text(stringResource(R.string.login_outdated_title)) }, - text = { Text(stringResource(R.string.login_outdated_message)) }, - confirmButton = { - TextButton(onClick = { - dialogViewModel.dismissLoginOutdated() - onLogin() - }) { - Text(stringResource(R.string.oauth_expired_login_again)) - } - }, - dismissButton = { - TextButton(onClick = dialogViewModel::dismissLoginOutdated) { - Text(stringResource(R.string.dialog_dismiss)) - } - } + 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 (dialogState.showLoginExpired) { - AlertDialog( - onDismissRequest = dialogViewModel::dismissLoginExpired, - title = { Text(stringResource(R.string.oauth_expired_title)) }, - text = { Text(stringResource(R.string.oauth_expired_message)) }, - confirmButton = { - TextButton(onClick = { - dialogViewModel.dismissLoginExpired() - onLogin() - }) { - Text(stringResource(R.string.oauth_expired_login_again)) - } + 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() }, - dismissButton = { - TextButton(onClick = dialogViewModel::dismissLoginExpired) { - Text(stringResource(R.string.dialog_dismiss)) - } - } ) } 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 index be2f60a7a..c60c38d08 100644 --- 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 @@ -113,6 +113,7 @@ data class TourOverlayState( val configureActionsTooltipState: TooltipState? = null, val swipeGestureTooltipState: TooltipState? = null, val forceOverflowOpen: Boolean = false, + val isTourActive: Boolean = false, val onAdvance: (() -> Unit)? = null, val onSkip: (() -> Unit)? = null, ) @@ -234,7 +235,7 @@ fun ChatInputLayout( // Text Field TextField( state = textFieldState, - enabled = enabled, + enabled = enabled && !tourState.isTourActive, modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) @@ -788,7 +789,7 @@ private fun InputActionsRow( } onEmoteClick() }, - enabled = enabled, + enabled = enabled && !tourState.isTourActive, modifier = Modifier.size(iconSize) ) { Icon( @@ -977,7 +978,14 @@ internal fun TooltipScope.TourTooltip( onSkip: () -> Unit, isLast: Boolean = false, ) { + 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)) { 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 index 74f47054b..c0a0d8b0c 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -19,14 +21,18 @@ 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.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.data.debug.DebugEntry import com.flxrs.dankchat.utils.compose.BottomSheetNestedScrollConnection +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,8 +83,19 @@ fun DebugInfoSheet( @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 = Modifier + modifier = copyModifier .fillMaxWidth() .padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, 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 index a75cedc6e..aac62301a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt @@ -5,6 +5,8 @@ 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 @@ -55,6 +57,7 @@ data class FeatureTourUiState( @KoinViewModel class FeatureTourViewModel( private val onboardingDataStore: OnboardingDataStore, + startupValidationHolder: StartupValidationHolder, ) : ViewModel() { // Material3 tooltip states — UI objects exposed directly, not in the StateFlow. @@ -87,7 +90,8 @@ class FeatureTourViewModel( _tourState, _channelState, _toolbarHintDone, - ) { settings, tour, channel, hintDone -> + startupValidationHolder.state, + ) { settings, tour, channel, hintDone, validation -> val currentStep = when { !tour.isActive -> null tour.stepIndex >= TourStep.entries.size -> null @@ -101,6 +105,7 @@ class FeatureTourViewModel( toolbarHintDone = hintDone || settings.hasShownToolbarHint, tourActive = tour.isActive, tourCompleted = tour.completed, + authValidated = validation is StartupValidation.Validated, ), currentTourStep = currentStep, isTourActive = tour.isActive, @@ -238,10 +243,12 @@ class FeatureTourViewModel( 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 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 index fa5585acb..3508ede81 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.unit.Velocity object BottomSheetNestedScrollConnection : NestedScrollConnection { override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = when (source) { - NestedScrollSource.Fling -> available.copy(x = 0f) + NestedScrollSource.SideEffect -> available.copy(x = 0f) else -> Offset.Zero } 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..62ec17593 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt @@ -0,0 +1,141 @@ +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.composables.core.rememberModalBottomSheetState as rememberUnstyledSheetState +import com.flxrs.dankchat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InfoBottomSheet( + title: String, + message: String, + confirmText: String, + dismissText: String = stringResource(R.string.dialog_dismiss), + dismissible: Boolean = true, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + when { + dismissible -> { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + 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.surfaceContainerLow, + 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) + } + } + } +} From 1e70d044f8079a5a578f9b23ffabd1bcd10cfac9 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 14:37:33 +0100 Subject: [PATCH 136/349] feat(onboarding): Add permissions disclaimer to login page, fix reset onboarding, improve message history readability --- .../dankchat/ui/onboarding/OnboardingDataStore.kt | 3 ++- .../dankchat/ui/onboarding/OnboardingScreen.kt | 13 +++++++++++-- .../dankchat/ui/onboarding/OnboardingSettings.kt | 1 + app/src/main/res/values-b+zh+Hant+TW/strings.xml | 3 ++- app/src/main/res/values-be-rBY/strings.xml | 3 ++- app/src/main/res/values-ca/strings.xml | 3 ++- app/src/main/res/values-cs/strings.xml | 3 ++- app/src/main/res/values-de-rDE/strings.xml | 3 ++- app/src/main/res/values-en-rAU/strings.xml | 3 ++- app/src/main/res/values-en-rGB/strings.xml | 3 ++- app/src/main/res/values-en/strings.xml | 3 ++- app/src/main/res/values-es-rES/strings.xml | 3 ++- app/src/main/res/values-fi-rFI/strings.xml | 3 ++- app/src/main/res/values-fr-rFR/strings.xml | 3 ++- app/src/main/res/values-hu-rHU/strings.xml | 3 ++- app/src/main/res/values-it/strings.xml | 3 ++- app/src/main/res/values-ja-rJP/strings.xml | 3 ++- app/src/main/res/values-kk-rKZ/strings.xml | 3 ++- app/src/main/res/values-or-rIN/strings.xml | 3 ++- app/src/main/res/values-pl-rPL/strings.xml | 3 ++- app/src/main/res/values-pt-rBR/strings.xml | 3 ++- app/src/main/res/values-pt-rPT/strings.xml | 3 ++- app/src/main/res/values-ru-rRU/strings.xml | 3 ++- app/src/main/res/values-sr/strings.xml | 3 ++- app/src/main/res/values-tr-rTR/strings.xml | 3 ++- app/src/main/res/values-uk-rUA/strings.xml | 3 ++- app/src/main/res/values/strings.xml | 3 ++- 27 files changed, 62 insertions(+), 27 deletions(-) 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 index 135338624..e741fbf5c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt @@ -25,12 +25,13 @@ class OnboardingDataStore( // If so, they've used the app before and should skip onboarding. private val existingUserMigration = object : DataMigration { override suspend fun shouldMigrate(currentData: OnboardingSettings): Boolean { - return !currentData.hasCompletedOnboarding && dankChatPreferenceStore.hasMessageHistoryAcknowledged + return !currentData.hasRunExistingUserMigration && dankChatPreferenceStore.hasMessageHistoryAcknowledged } override suspend fun migrate(currentData: OnboardingSettings): OnboardingSettings { return currentData.copy( hasCompletedOnboarding = true, + hasRunExistingUserMigration = true, hasShownAddChannelHint = true, hasShownToolbarHint = true, ) 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 index 83e16a74e..96c8178f3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt @@ -225,7 +225,16 @@ private fun LoginPage( ) }, title = stringResource(R.string.onboarding_login_title), - body = { OnboardingBody(stringResource(R.string.onboarding_login_body)) }, + 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( @@ -308,7 +317,7 @@ private fun MessageHistoryPage( } Text( text = annotatedBody, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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 index 280694787..7bf2ef223 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt @@ -5,6 +5,7 @@ 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, 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 49ca57c76..0f2ff5cb8 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 @@ -618,12 +618,13 @@ 開始使用 使用 Twitch 登入 登入以傳送訊息、使用您的表情符號、接收悄悄話,並解鎖所有功能。 + 系統會一次性要求您授予數項 Twitch 權限,這樣您在使用不同功能時就不需要重新授權。DankChat 僅在您主動要求時才會執行管理或直播相關操作。 使用 Twitch 登入 登入成功 略過 繼續 訊息紀錄 - DankChat 可於啟動時透過第三方服務載入歷史訊息。\n為了取得這些訊息,DankChat 會將您開啟的頻道名稱傳送至該服務。\n該服務會暫時儲存您(及其他人)造訪的頻道訊息以提供此功能。\n\n您可以稍後在設定中變更此選項,或透過 https://recent-messages.robotty.de/ 了解更多資訊 + DankChat 可於啟動時透過第三方服務載入歷史訊息。 為了取得這些訊息,DankChat 會將您開啟的頻道名稱傳送至該服務。 該服務會暫時儲存您(及其他人)造訪的頻道訊息以提供此功能。\n\n您可以稍後在設定中變更此選項,或透過 https://recent-messages.robotty.de/ 了解更多資訊 啟用 停用 通知 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 7e9118a8b..64e12c90f 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -645,6 +645,7 @@ Давайце ўсё наладзім. Увайсці праз Twitch Увайдзіце, каб адпраўляць паведамленні, выкарыстоўваць свае эмоуты, атрымліваць шэпты і разблакаваць усе функцыі. + Вам будзе прапанавана даць некалькі дазволаў Twitch адразу, каб вам не давялося паўторна аўтарызавацца пры выкарыстанні розных функцый. DankChat выконвае дзеянні мадэрацыі і кіравання стрымам толькі тады, калі вы самі гэта запытваеце. Увайсці праз Twitch Уваход паспяховы Апавяшчэнні @@ -653,7 +654,7 @@ Адкрыць налады апавяшчэнняў Без апавяшчэнняў вы не даведаецеся, калі нехта згадвае вас у чаце, пакуль праграма працуе ў фоне. Гісторыя паведамленняў - DankChat загружае гістарычныя паведамленні са старонняга сэрвісу пры запуску.\nДля атрымання паведамленняў DankChat адпраўляе назвы адкрытых каналаў гэтаму сэрвісу.\nСэрвіс часова захоўвае паведамленні наведаных каналаў.\n\nВы можаце змяніць гэта пазней у наладах або даведацца больш на https://recent-messages.robotty.de/ + DankChat загружае гістарычныя паведамленні са старонняга сэрвісу пры запуску. Для атрымання паведамленняў DankChat адпраўляе назвы адкрытых каналаў гэтаму сэрвісу. Сэрвіс часова захоўвае паведамленні наведаных каналаў.\n\nВы можаце змяніць гэта пазней у наладах або даведацца больш на https://recent-messages.robotty.de/ Уключыць Адключыць Працягнуць diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index e30cc7390..911304cca 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -660,6 +660,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 @@ -668,7 +669,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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.\nPer obtenir els missatges, DankChat envia els noms dels canals oberts a aquest servei.\nEl 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/ + 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 587f8dd1e..24d8d66e3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -646,6 +646,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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í @@ -654,7 +655,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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í.\nPro získání zpráv DankChat odesílá názvy otevřených kanálů této službě.\nSluž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/ + 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 29d2c7b55..312888b99 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -644,6 +644,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 @@ -652,7 +653,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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.\nUm die Nachrichten abzurufen, sendet DankChat die Namen der geöffneten Kanäle an diesen Dienst.\nDer 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/ + 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 98f1de13d..ce8a6197f 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -448,6 +448,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 @@ -456,7 +457,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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/ + 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 440d817ec..878caaae5 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -449,6 +449,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 @@ -457,7 +458,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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/ + 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2fcf8c13f..7cfee10de 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -638,6 +638,7 @@ 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 @@ -646,7 +647,7 @@ 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.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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/ + 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index f46b1a832..f12cc2097 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -654,6 +654,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 @@ -662,7 +663,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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.\nPara obtener los mensajes, DankChat envía los nombres de los canales abiertos a ese servicio.\nEl 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/ + 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index ad951ab32..059c1f160 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -635,6 +635,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 @@ -643,7 +644,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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ä.\nViestien hakemiseksi DankChat lähettää avattujen kanavien nimet tälle palvelulle.\nPalvelu tallentaa tilapäisesti vierailtujen kanavien viestejä.\n\nVoit muuttaa tätä myöhemmin asetuksista tai lukea lisää osoitteessa https://recent-messages.robotty.de/ + 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 5da58eeeb..7609b3bc2 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -638,6 +638,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 @@ -646,7 +647,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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.\nPour obtenir les messages, DankChat envoie les noms des chaînes ouvertes à ce service.\nLe 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/ + 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index e722a5806..b60e16310 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -622,6 +622,7 @@ Á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 @@ -630,7 +631,7 @@ É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.\nAz üzenetek lekéréséhez a DankChat elküldi a megnyitott csatornák neveit ennek a szolgáltatásnak.\nA 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. + 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b75415477..b1a58940e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -621,6 +621,7 @@ 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 @@ -629,7 +630,7 @@ 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.\nPer ottenere i messaggi, DankChat invia i nomi dei canali aperti a questo servizio.\nIl 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/ + 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 2f9e8774e..8107d2b91 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -602,6 +602,7 @@ セットアップを始めましょう。 Twitchでログイン ログインして、メッセージの送信、エモートの使用、ウィスパーの受信、すべての機能をお楽しみください。 + 複数のTwitch権限をまとめて許可するよう求められます。これにより、異なる機能を使うたびに再認証する必要がなくなります。DankChatがモデレーションや配信の操作を行うのは、あなたが指示したときだけです。 Twitchでログイン ログイン成功 通知 @@ -610,7 +611,7 @@ 通知設定を開く 通知がないと、アプリがバックグラウンドにある時にチャットで誰かがあなたをメンションしても気づけません。 メッセージ履歴 - DankChatは起動時にサードパーティサービスから過去のメッセージを読み込みます。\nメッセージを取得するために、DankChatは開いているチャンネル名をそのサービスに送信します。\nサービスは訪問されたチャンネルのメッセージを一時的に保存します。\n\nこれは後で設定から変更できます。詳細は https://recent-messages.robotty.de/ をご覧ください。 + DankChatは起動時にサードパーティサービスから過去のメッセージを読み込みます。 メッセージを取得するために、DankChatは開いているチャンネル名をそのサービスに送信します。 サービスは訪問されたチャンネルのメッセージを一時的に保存します。\n\nこれは後で設定から変更できます。詳細は https://recent-messages.robotty.de/ をご覧ください。 有効にする 無効にする 続ける diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 576f5d1ed..08bd737f5 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -618,12 +618,13 @@ Бастау Twitch арқылы кіру Хабарлар жіберу, эмоцияларды пайдалану, сыбырлар алу және барлық мүмкіндіктерді ашу үшін жүйеге кіріңіз. + Сізден бірнеше Twitch рұқсаттарын бірден беру сұралады, сондықтан әртүрлі мүмкіндіктерді пайдаланғанда қайта рұқсат берудің қажеті болмайды. DankChat модерация мен трансляция әрекеттерін тек сіз сұраған кезде ғана орындайды. Twitch арқылы кіру Кіру сәтті аяқталды Өткізіп жіберу Жалғастыру Хабар тарихы - DankChat іске қосылған кезде үшінші тарап қызметінен тарихи хабарларды жүктейді.\nХабарларды алу үшін DankChat сол қызметке ашылған арналардың атауларын жібереді.\nҚызмет көрсету үшін сіз (және басқалар) баратын арналар үшін хабарларды уақытша сақтайды.\n\nБұны кейінірек параметрлерден өзгертуге немесе https://recent-messages.robotty.de/ сайтында толығырақ білуге болады + DankChat іске қосылған кезде үшінші тарап қызметінен тарихи хабарларды жүктейді. Хабарларды алу үшін DankChat сол қызметке ашылған арналардың атауларын жібереді. Қызмет көрсету үшін сіз (және басқалар) баратын арналар үшін хабарларды уақытша сақтайды.\n\nБұны кейінірек параметрлерден өзгертуге немесе https://recent-messages.robotty.de/ сайтында толығырақ білуге болады Қосу Өшіру Хабарландырулар diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 202184511..b5b5192b9 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -618,12 +618,13 @@ ଆରମ୍ଭ କରନ୍ତୁ Twitch ସହ ଲଗଇନ୍ କରନ୍ତୁ ବାର୍ତ୍ତା ପଠାଇବାକୁ, ଆପଣଙ୍କ ଇମୋଟ୍ ବ୍ୟବହାର କରିବାକୁ, ଫୁସ୍ଫୁସ୍ ଗ୍ରହଣ କରିବାକୁ ଏବଂ ସମସ୍ତ ବୈଶିଷ୍ଟ୍ୟ ଅନଲକ୍ କରିବାକୁ ଲଗ ଇନ କରନ୍ତୁ। + ଆପଣଙ୍କୁ ଏକାସାଥରେ ଅନେକ Twitch ଅନୁମତି ପ୍ରଦାନ କରିବାକୁ କୁହାଯିବ, ଯାହାଫଳରେ ବିଭିନ୍ନ ବୈଶିଷ୍ଟ୍ୟ ବ୍ୟବହାର କରିବାବେଳେ ଆପଣଙ୍କୁ ପୁନଃ ଅନୁମୋଦନ କରିବାକୁ ପଡ଼ିବ ନାହିଁ। DankChat କେବଳ ଆପଣ କହିଲେ ମଡରେସନ୍ ଏବଂ ଷ୍ଟ୍ରିମ୍ କାର୍ଯ୍ୟ କରିଥାଏ। Twitch ସହ ଲଗଇନ୍ କରନ୍ତୁ ଲଗଇନ୍ ସଫଳ ଛାଡି ଦିଅନ୍ତୁ ଜାରି ରଖନ୍ତୁ ବାର୍ତ୍ତା ଇତିହାସ - DankChat ଆରମ୍ଭରେ ତୃତୀୟ-ପକ୍ଷ ସେବାରୁ ଐତିହାସିକ ବାର୍ତ୍ତା ଲୋଡ୍ କରେ।\nବାର୍ତ୍ତା ପାଇବାକୁ, DankChat ଆପଣ ସେହି ସେବାରେ ଖୋଲିଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ନାମ ପଠାଏ।\nସେବା ସାମୟିକ ଭାବରେ ସେବା ପ୍ରଦାନ ପାଇଁ ଆପଣ (ଏବଂ ଅନ୍ୟମାନେ) ପରିଦର୍ଶନ କରୁଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ବାର୍ତ୍ତା ସଂରକ୍ଷଣ କରେ।\n\nଆପଣ ଏହାକୁ ପରବର୍ତ୍ତୀ ସମୟରେ ସେଟିଂସରେ ବଦଳାଇ ପାରିବେ କିମ୍ବା https://recent-messages.robotty.de/ ରେ ଅଧିକ ଜାଣିପାରିବେ + DankChat ଆରମ୍ଭରେ ତୃତୀୟ-ପକ୍ଷ ସେବାରୁ ଐତିହାସିକ ବାର୍ତ୍ତା ଲୋଡ୍ କରେ। ବାର୍ତ୍ତା ପାଇବାକୁ, DankChat ଆପଣ ସେହି ସେବାରେ ଖୋଲିଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ନାମ ପଠାଏ। ସେବା ସାମୟିକ ଭାବରେ ସେବା ପ୍ରଦାନ ପାଇଁ ଆପଣ (ଏବଂ ଅନ୍ୟମାନେ) ପରିଦର୍ଶନ କରୁଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ବାର୍ତ୍ତା ସଂରକ୍ଷଣ କରେ।\n\nଆପଣ ଏହାକୁ ପରବର୍ତ୍ତୀ ସମୟରେ ସେଟିଂସରେ ବଦଳାଇ ପାରିବେ କିମ୍ବା https://recent-messages.robotty.de/ ରେ ଅଧିକ ଜାଣିପାରିବେ ସକ୍ଷମ କରନ୍ତୁ ଅକ୍ଷମ କରନ୍ତୁ ଵିଜ୍ଞପ୍ତି diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index de3e06af7..7728d17f6 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -664,6 +664,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 @@ -672,7 +673,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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.\nAby pobrać wiadomości, DankChat wysyła nazwy otwartych kanałów do tego serwisu.\nSerwis 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/ + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 66eecbb5b..995190367 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -633,6 +633,7 @@ 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 @@ -641,7 +642,7 @@ 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.\nPara obter as mensagens, o DankChat envia os nomes dos canais abertos para esse serviço.\nO 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/ + 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index cf446f675..8cab0ad5d 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -623,6 +623,7 @@ 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 @@ -631,7 +632,7 @@ 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.\nPara obter as mensagens, o DankChat envia os nomes dos canais abertos para esse serviço.\nO 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/ + 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 23a506781..3ea55b6fa 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -650,6 +650,7 @@ Давайте настроим всё. Войти через Twitch Войдите, чтобы отправлять сообщения, использовать свои эмоуты, получать личные сообщения и разблокировать все функции. + Вам будет предложено предоставить сразу несколько разрешений Twitch, чтобы вам не пришлось повторно авторизоваться при использовании различных функций. DankChat выполняет действия по модерации и управлению трансляцией только по вашему запросу. Войти через Twitch Вход выполнен Уведомления @@ -658,7 +659,7 @@ Открыть настройки уведомлений Без уведомлений вы не узнаете, когда кто-то упоминает вас в чате, пока приложение работает в фоне. История сообщений - DankChat загружает историю сообщений из стороннего сервиса при запуске.\nДля получения сообщений DankChat отправляет названия открытых каналов этому сервису.\nСервис временно хранит сообщения посещённых каналов.\n\nВы можете изменить это позже в настройках или узнать больше на https://recent-messages.robotty.de/ + DankChat загружает историю сообщений из стороннего сервиса при запуске. Для получения сообщений DankChat отправляет названия открытых каналов этому сервису. Сервис временно хранит сообщения посещённых каналов.\n\nВы можете изменить это позже в настройках или узнать больше на https://recent-messages.robotty.de/ Включить Отключить Продолжить diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index ccf55a25e..7f5ac4a97 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -664,6 +664,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Хајде да подесимо све. Пријави се преко Twitch-а Пријавите се да бисте слали поруке, користили емотиконе, примали шапате и откључали све функције. + Бићете замољени да одобрите неколико Twitch дозвола одједном како не бисте морали поново да се ауторизујете при коришћењу различитих функција. DankChat извршава радње модерације и управљања преносом само када то затражите. Пријави се преко Twitch-а Пријава успешна Обавештења @@ -672,7 +673,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Отвори подешавања обавештења Без обавештења нећете знати када вас неко помене у чату док апликација ради у позадини. Историја порука - DankChat учитава историјске поруке из услуге треће стране при покретању.\nДа би добио поруке, DankChat шаље имена отворених канала овој услузи.\nУслуга привремено чува поруке посећених канала.\n\nОво можете касније променити у подешавањима или сазнати више на https://recent-messages.robotty.de/ + DankChat учитава историјске поруке из услуге треће стране при покретању. Да би добио поруке, DankChat шаље имена отворених канала овој услузи. Услуга привремено чува поруке посећених канала.\n\nОво можете касније променити у подешавањима или сазнати више на https://recent-messages.robotty.de/ Укључи Искључи Настави diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 8db081091..62eb5696c 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -643,6 +643,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 @@ -651,7 +652,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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.\nMesajları almak için DankChat, açık kanalların adlarını bu hizmete gönderir.\nHizmet, 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. + 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index becf021d1..7884ba920 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -647,6 +647,7 @@ Давайте все налаштуємо. Увійти через Twitch Увійдіть, щоб надсилати повідомлення, використовувати свої емоути, отримувати шепіт та розблокувати всі функції. + Вам буде запропоновано надати кілька дозволів Twitch одразу, щоб вам не довелося повторно авторизуватися при використанні різних функцій. DankChat виконує дії з модерації та керування трансляцією лише за вашим запитом. Увійти через Twitch Вхід виконано Сповіщення @@ -655,7 +656,7 @@ Відкрити налаштування сповіщень Без сповіщень ви не дізнаєтесь, коли хтось згадує вас у чаті, поки застосунок працює у фоні. Історія повідомлень - DankChat завантажує історичні повідомлення зі стороннього сервісу при запуску.\nДля отримання повідомлень DankChat надсилає назви відкритих каналів цьому сервісу.\nСервіс тимчасово зберігає повідомлення відвіданих каналів.\n\nВи можете змінити це пізніше в налаштуваннях або дізнатися більше на https://recent-messages.robotty.de/ + DankChat завантажує історичні повідомлення зі стороннього сервісу при запуску. Для отримання повідомлень DankChat надсилає назви відкритих каналів цьому сервісу. Сервіс тимчасово зберігає повідомлення відвіданих каналів.\n\nВи можете змінити це пізніше в налаштуваннях або дізнатися більше на https://recent-messages.robotty.de/ Увімкнути Вимкнути Продовжити diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8df6292f5..ffcf621ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -704,12 +704,13 @@ 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.\nTo get the messages, DankChat sends the names of the channels you have open to that service.\nThe 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/ + 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 From 5cbfa50c3163bd426cada4bd0a6bc2f5061f4a8d Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 14:50:54 +0100 Subject: [PATCH 137/349] feat(chat): Add Debug system message type with reduced opacity for EventSub debug output --- .../dankchat/data/repo/chat/ChatEventProcessor.kt | 11 ++++++++++- .../dankchat/data/twitch/message/SystemMessageType.kt | 2 ++ .../com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) 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 index 1bebcb454..5f8703273 100644 --- 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 @@ -32,6 +32,7 @@ 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.toDebugChatItem import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage import com.flxrs.dankchat.data.twitch.message.WhisperMessage import com.flxrs.dankchat.data.twitch.message.hasMention @@ -138,7 +139,7 @@ class ChatEventProcessor( is AutomodUpdate -> handleAutomodUpdate(eventMessage) is UserMessageHeld -> handleUserMessageHeld(eventMessage) is UserMessageUpdated -> handleUserMessageUpdated(eventMessage) - is SystemMessage -> postSystemMessageAndReconnect(type = SystemMessageType.Custom(eventMessage.message)) + is SystemMessage -> postEventSubDebugMessage(eventMessage.message) } } } @@ -529,6 +530,14 @@ class ChatEventProcessor( 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 -> 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 6eda86b7d..b8f1e8bb6 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 @@ -23,7 +23,9 @@ sealed interface 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 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/ui/chat/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt index 3b7541325..fa4bae5b6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -143,6 +143,7 @@ class ChatMessageMapper( 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 -> TextResource.Plain(type.message) + is SystemMessageType.Debug -> TextResource.Plain(type.message) 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)) From e71a68fb2d5ab11e0ba02c009311bcf514bcf6b6 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:05:38 +0100 Subject: [PATCH 138/349] fix(emotes): Reparse emotes in mentions and whispers when emote data changes --- .../data/repo/chat/ChatMessageRepository.kt | 2 ++ .../repo/chat/ChatNotificationRepository.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) 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 index e9944c282..2c9c27b76 100644 --- 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 @@ -32,6 +32,7 @@ import java.util.concurrent.ConcurrentHashMap @Single class ChatMessageRepository( private val messageProcessor: MessageProcessor, + private val chatNotificationRepository: ChatNotificationRepository, chatSettingsDataStore: ChatSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { @@ -137,6 +138,7 @@ class ChatMessageRepository( } } }.awaitAll() + chatNotificationRepository.reparseAll() } fun addSystemMessage(channel: UserName, type: SystemMessageType) { 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 index 6c4c6078d..d5626e4ec 100644 --- 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 @@ -55,6 +55,25 @@ class ChatNotificationRepository( 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 -> From 2aea313bdf432d60b70a7db3160ce5a0d39f6a81 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:28:11 +0100 Subject: [PATCH 139/349] refactor(highlights): Extract duplicated color picker into HighlightColorPicker composable --- .../highlights/HighlightsScreen.kt | 270 ++++++------------ 1 file changed, 84 insertions(+), 186 deletions(-) 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 4220bd5a5..32771b7ca 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 @@ -453,70 +453,12 @@ private fun MessageHighlightItem( 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 }, + 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) + onColorSelected = { onChanged(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) - } - ) - } - } } } @@ -560,70 +502,12 @@ private fun UserHighlightItem( ) } 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 }, + 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) + onColorSelected = { onChanged(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, @@ -699,70 +583,12 @@ private fun BadgeHighlightItem( ) } 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 }, + 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) + onColorSelected = { onChanged(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( @@ -827,3 +653,75 @@ private fun BlacklistedUserItem( } } } + +@Composable +private fun HighlightColorPicker( + color: Int, + defaultColor: Int, + enabled: Boolean, + onColorSelected: (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 = { + onColorSelected(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) + } + ) + } + } +} From a44a2669562a992a91f6eca1d91d3dc2b9f17188 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:35:33 +0100 Subject: [PATCH 140/349] refactor(ui): Move DataStore and Repository access from composables into ViewModels --- .../kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 10 +--------- .../dankchat/ui/main/dialog/DialogStateViewModel.kt | 7 +++++++ .../flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt | 9 ++------- .../dankchat/ui/main/dialog/ModActionsViewModel.kt | 4 ++++ 4 files changed, 14 insertions(+), 16 deletions(-) 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 index 212074de6..faa65d43e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -104,7 +104,6 @@ import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.InputAction import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior import com.flxrs.dankchat.preferences.components.DankBackground -import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore import com.flxrs.dankchat.ui.chat.ChatComposable import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker import com.flxrs.dankchat.ui.chat.mention.MentionViewModel @@ -280,8 +279,6 @@ fun MainScreen( val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() - val toolsSettingsDataStore: ToolsSettingsDataStore = koinInject() - val sheetNavState by sheetNavigationViewModel.sheetState.collectAsStateWithLifecycle() val fullScreenSheetState = sheetNavState.fullScreenSheet val isSheetOpen = fullScreenSheetState !is FullScreenSheetState.Closed @@ -368,15 +365,10 @@ fun MainScreen( // External hosting upload disclaimer dialog if (dialogState.pendingUploadAction != null) { - val uploadHost = remember { - runCatching { - java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host - }.getOrElse { "" } - } AlertDialog( onDismissRequest = { dialogViewModel.setPendingUploadAction(null) }, title = { Text(stringResource(R.string.nuuls_upload_title)) }, - text = { Text(stringResource(R.string.external_upload_disclaimer, uploadHost)) }, + text = { Text(stringResource(R.string.external_upload_disclaimer, dialogViewModel.uploadHost)) }, confirmButton = { TextButton( onClick = { 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 index 195ff11a1..2d646fef3 100644 --- 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.DankChatPreferenceStore +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.ImmutableList @@ -16,6 +17,7 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class DialogStateViewModel( private val preferenceStore: DankChatPreferenceStore, + private val toolsSettingsDataStore: ToolsSettingsDataStore, ) : ViewModel() { private val _state = MutableStateFlow(DialogState()) @@ -81,6 +83,11 @@ class DialogStateViewModel( } // Upload + val uploadHost: String + get() = runCatching { + java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host + }.getOrDefault("") + fun setPendingUploadAction(action: (() -> Unit)?) { update { copy(pendingUploadAction = action) } } 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 index d1ef31b8b..ff362ed42 100644 --- 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 @@ -16,7 +16,6 @@ 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.channel.ChannelRepository import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.ui.chat.BadgeUi import com.flxrs.dankchat.utils.compose.InfoBottomSheet @@ -64,7 +63,6 @@ fun MainScreenDialogs( val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() - val channelRepository: ChannelRepository = koinInject() val startupValidationHolder: StartupValidationHolder = koinInject() val startupValidation by startupValidationHolder.state.collectAsStateWithLifecycle() @@ -87,17 +85,14 @@ fun MainScreenDialogs( } if (dialogState.showModActions && modActionsChannel != null) { - val preferenceStore: DankChatPreferenceStore = koinInject() - val roomState = channelRepository.getRoomState(modActionsChannel) - val isBroadcaster = preferenceStore.userIdString == roomState?.channelId val modActionsViewModel: ModActionsViewModel = koinViewModel( key = "mod-actions-${modActionsChannel.value}", parameters = { parametersOf(modActionsChannel) } ) val shieldModeActive by modActionsViewModel.shieldModeActive.collectAsStateWithLifecycle() ModActionsDialog( - roomState = roomState, - isBroadcaster = isBroadcaster, + roomState = modActionsViewModel.roomState, + isBroadcaster = modActionsViewModel.isBroadcaster, isStreamActive = isStreamActive, shieldModeActive = shieldModeActive, onSendCommand = { command -> 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 index 9d354b532..23acc5acf 100644 --- 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 @@ -6,6 +6,7 @@ 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 com.flxrs.dankchat.data.twitch.message.RoomState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,6 +25,9 @@ class ModActionsViewModel( private val _shieldModeActive = MutableStateFlow(null) val shieldModeActive: StateFlow = _shieldModeActive.asStateFlow() + val roomState: RoomState? get() = channelRepository.getRoomState(channel) + val isBroadcaster: Boolean get() = authDataStore.userIdString == roomState?.channelId + init { fetchShieldMode() } From 6ad9ba247d6531b2d05ceb67903c93e4d1b1fcd5 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:39:40 +0100 Subject: [PATCH 141/349] refactor(chat): Extract duplicated user annotation parsing into parseUserAnnotation helper --- .../dankchat/ui/chat/messages/PrivMessage.kt | 31 +++++-------------- .../ui/chat/messages/WhisperAndRedemption.kt | 29 +++++------------ .../messages/common/MessageTextBuilders.kt | 28 +++++++++++++++++ 3 files changed, 42 insertions(+), 46 deletions(-) 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 index dc2e29c3d..9ccf63675 100644 --- 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 @@ -42,6 +42,7 @@ import com.flxrs.dankchat.ui.chat.ChatMessageUiState import com.flxrs.dankchat.ui.chat.appendWithLinks import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent 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.timestampSpanStyle import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor @@ -274,13 +275,8 @@ private fun PrivMessageText( onTextClick = { offset -> annotatedString.getStringAnnotations("USER", offset, offset) .firstOrNull()?.let { annotation -> - val parts = annotation.item.split("|") - if (parts.size == 4) { - val userId = parts[0].takeIf { it.isNotEmpty() } - val userName = parts[1] - val displayName = parts[2] - val channel = parts[3] - onUserClick(userId, userName, displayName, channel, message.badges, false) + parseUserAnnotation(annotation.item)?.let { user -> + onUserClick(user.userId, user.userName, user.displayName, user.channel.orEmpty(), message.badges, false) } } @@ -290,25 +286,12 @@ private fun PrivMessageText( } }, onTextLongClick = { offset -> - val userAnnotation = if (offset >= 0) { - annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() - } else { - null - } + val user = annotatedString.getStringAnnotations("USER", offset, offset) + .firstOrNull()?.let { parseUserAnnotation(it.item) } when { - userAnnotation != null -> { - val parts = userAnnotation.item.split("|") - if (parts.size == 4) { - val userId = parts[0].takeIf { it.isNotEmpty() } - val userName = parts[1] - val displayName = parts[2] - val channel = parts[3] - onUserClick(userId, userName, displayName, channel, message.badges, true) - } - } - - else -> onMessageLongClick(message.id, message.channel.value, message.fullMessage) + user != null -> onUserClick(user.userId, user.userName, user.displayName, user.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/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt index f75e75682..b07c89cb4 100644 --- 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 @@ -41,6 +41,7 @@ import com.flxrs.dankchat.ui.chat.ChatMessageUiState import com.flxrs.dankchat.ui.chat.appendWithLinks import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent 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.timestampSpanStyle import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor @@ -207,12 +208,8 @@ private fun WhisperMessageText( onTextClick = { offset -> annotatedString.getStringAnnotations("USER", offset, offset) .firstOrNull()?.let { annotation -> - val parts = annotation.item.split("|") - if (parts.size == 3) { - val userId = parts[0].takeIf { it.isNotEmpty() } - val userName = parts[1] - val displayName = parts[2] - onUserClick(userId, userName, displayName, message.badges, false) + parseUserAnnotation(annotation.item)?.let { user -> + onUserClick(user.userId, user.userName, user.displayName, message.badges, false) } } @@ -222,24 +219,12 @@ private fun WhisperMessageText( } }, onTextLongClick = { offset -> - val userAnnotation = if (offset >= 0) { - annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() - } else { - null - } + val user = annotatedString.getStringAnnotations("USER", offset, offset) + .firstOrNull()?.let { parseUserAnnotation(it.item) } when { - userAnnotation != null -> { - val parts = userAnnotation.item.split("|") - if (parts.size == 3) { - val userId = parts[0].takeIf { it.isNotEmpty() } - val userName = parts[1] - val displayName = parts[2] - onUserClick(userId, userName, displayName, message.badges, true) - } - } - - else -> onMessageLongClick(message.id, message.fullMessage) + user != null -> onUserClick(user.userId, user.userName, user.displayName, message.badges, true) + else -> onMessageLongClick(message.id, message.fullMessage) } }, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt index 726d0d757..500d9cddb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt @@ -133,6 +133,34 @@ fun AnnotatedString.Builder.appendClickableUsername( } } +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 + } +} + /** * Builds inline content providers for badges and emotes. */ From 9a5da2a835c6eab25531dd86f6863240545f3a5f Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:44:44 +0100 Subject: [PATCH 142/349] refactor(chat): Extract ChatScreen callbacks into ChatScreenCallbacks data class --- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 14 +++-- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 63 +++++++------------ .../ui/chat/mention/MentionComposable.kt | 11 ++-- .../ui/chat/replies/RepliesComposable.kt | 8 ++- .../ui/main/sheet/MessageHistorySheet.kt | 9 ++- 5 files changed, 49 insertions(+), 56 deletions(-) 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 index dc65661a9..d65cc2043 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -66,13 +66,17 @@ fun ChatComposable( 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) }, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = modifier.fillMaxSize(), - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick, showInput = showInput, isFullscreen = isFullscreen, showFabs = showFabs, @@ -83,8 +87,6 @@ fun ChatComposable( onScrollDirectionChanged = onScrollDirectionChanged, scrollToMessageId = scrollToMessageId, onScrollToMessageHandled = onScrollToMessageHandled, - onAutomodAllow = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = true) }, - onAutomodDeny = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = false) }, recoveryFabTooltipState = recoveryFabTooltipState, onTourAdvance = onTourAdvance, onTourSkip = onTourSkip, 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 index 48ee4bcce..3995cf905 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -61,29 +61,26 @@ import com.flxrs.dankchat.ui.chat.messages.UserNoticeMessageComposable import com.flxrs.dankchat.ui.chat.messages.WhisperMessageComposable import com.flxrs.dankchat.ui.main.input.TourTooltip -/** - * Main composable for rendering chat messages in a scrollable list. - * - * Features: - * - LazyColumn with reverseLayout for bottom-anchored scrolling - * - Automatic scroll to bottom when new messages arrive - * - FAB to manually scroll to bottom - * - Efficient recomposition with stable keys - */ +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: List, fontSize: Float, - onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, - onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + callbacks: ChatScreenCallbacks, modifier: Modifier = Modifier, showChannelPrefix: Boolean = false, showLineSeparator: Boolean = false, animateGifs: Boolean = true, - onEmoteClick: (emotes: List) -> Unit = {}, - onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit = { _, _ -> }, - onWhisperReply: ((userName: UserName) -> Unit)? = null, showInput: Boolean = true, isFullscreen: Boolean = false, onRecover: () -> Unit = {}, @@ -93,8 +90,6 @@ fun ChatScreen( onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, scrollToMessageId: String? = null, onScrollToMessageHandled: () -> Unit = {}, - onAutomodAllow: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, - onAutomodDeny: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, containerColor: Color = MaterialTheme.colorScheme.background, showFabs: Boolean = true, recoveryFabTooltipState: TooltipState? = null, @@ -193,13 +188,7 @@ fun ChatScreen( fontSize = fontSize, showChannelPrefix = showChannelPrefix, animateGifs = animateGifs, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick, - onWhisperReply = onWhisperReply, - onAutomodAllow = onAutomodAllow, - onAutomodDeny = onAutomodDeny, + callbacks = callbacks, ) // Add divider after each message if enabled @@ -330,13 +319,7 @@ private fun ChatMessageItem( fontSize: Float, showChannelPrefix: Boolean, animateGifs: Boolean, - 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, - onWhisperReply: ((userName: UserName) -> Unit)? = null, - onAutomodAllow: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, - onAutomodDeny: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, + callbacks: ChatScreenCallbacks, ) { when (message) { is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( @@ -363,8 +346,8 @@ private fun ChatMessageItem( is ChatMessageUiState.AutomodMessageUi -> AutomodMessageComposable( message = message, fontSize = fontSize, - onAllow = onAutomodAllow, - onDeny = onAutomodDeny, + onAllow = callbacks.onAutomodAllow, + onDeny = callbacks.onAutomodDeny, ) is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( @@ -373,10 +356,10 @@ private fun ChatMessageItem( fontSize = fontSize, showChannelPrefix = showChannelPrefix, animateGifs = animateGifs, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onReplyClick = onReplyClick + onUserClick = callbacks.onUserClick, + onMessageLongClick = callbacks.onMessageLongClick, + onEmoteClick = callbacks.onEmoteClick, + onReplyClick = callbacks.onReplyClick ) is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( @@ -395,13 +378,13 @@ private fun ChatMessageItem( fontSize = fontSize, animateGifs = animateGifs, onUserClick = { userId, userName, displayName, badges, isLongPress -> - onUserClick(userId, userName, displayName, null, badges, isLongPress) + callbacks.onUserClick(userId, userName, displayName, null, badges, isLongPress) }, onMessageLongClick = { messageId, fullMessage -> - onMessageLongClick(messageId, null, fullMessage) + callbacks.onMessageLongClick(messageId, null, fullMessage) }, - onEmoteClick = onEmoteClick, - onWhisperReply = onWhisperReply + onEmoteClick = callbacks.onEmoteClick, + onWhisperReply = callbacks.onWhisperReply ) } } 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 index 6e4702efb..dbe1c007d 100644 --- 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 @@ -13,6 +13,7 @@ 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 com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator @@ -52,14 +53,16 @@ fun MentionComposable( ChatScreen( messages = messages, fontSize = displaySettings.fontSize, + callbacks = ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onWhisperReply = if (isWhisperTab) onWhisperReply else null, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, showChannelPrefix = !isWhisperTab, modifier = modifier, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onWhisperReply = if (isWhisperTab) onWhisperReply else null, contentPadding = contentPadding, scrollModifier = scrollModifier, containerColor = containerColor, 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 index 766c5f4b7..bde45719a 100644 --- 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 @@ -12,6 +12,7 @@ import coil3.compose.LocalPlatformContext import coil3.imageLoader 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.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator @@ -49,12 +50,13 @@ fun RepliesComposable( ChatScreen( messages = (uiState as RepliesUiState.Found).items, fontSize = displaySettings.fontSize, + callbacks = ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = modifier, - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = { /* no-op for replies */ }, contentPadding = contentPadding, scrollModifier = scrollModifier, containerColor = containerColor, 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 index c4c04a737..c6225cfa6 100644 --- 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 @@ -66,6 +66,7 @@ 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 com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker import com.flxrs.dankchat.ui.chat.history.MessageHistoryViewModel @@ -154,12 +155,14 @@ fun MessageHistorySheet( ChatScreen( messages = messages, fontSize = displaySettings.fontSize, + callbacks = ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = Modifier.fillMaxSize(), - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), scrollModifier = scrollModifier, containerColor = sheetBackgroundColor, From 7ed7011969d1a2a526e4bc57c8367de6e411c3a4 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:48:27 +0100 Subject: [PATCH 143/349] refactor(ui): Move isOwnUser check from composable into UserPopupViewModel --- .../com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt | 1 + .../com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt index 390e0bca9..7b2f31014 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt @@ -32,6 +32,7 @@ class UserPopupViewModel( private val _userPopupState = MutableStateFlow(UserPopupState.Loading(params.targetUserName, params.targetDisplayName)) val userPopupState: StateFlow = _userPopupState.asStateFlow() + val isOwnUser: Boolean get() = preferenceStore.userIdString == params.targetUserId init { loadData() 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 index ff362ed42..e33466996 100644 --- 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 @@ -16,7 +16,6 @@ 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.preferences.DankChatPreferenceStore import com.flxrs.dankchat.ui.chat.BadgeUi import com.flxrs.dankchat.utils.compose.InfoBottomSheet import com.flxrs.dankchat.ui.chat.emote.EmoteInfoViewModel @@ -249,12 +248,10 @@ fun MainScreenDialogs( parameters = { parametersOf(params) } ) val state by viewModel.userPopupState.collectAsStateWithLifecycle() - val preferenceStore: DankChatPreferenceStore = koinInject() - val isOwnUser = preferenceStore.userIdString == params.targetUserId UserPopupDialog( state = state, badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }, - isOwnUser = isOwnUser, + isOwnUser = viewModel.isOwnUser, onBlockUser = viewModel::blockUser, onUnblockUser = viewModel::unblockUser, onDismiss = dialogViewModel::dismissUserPopup, From 67ca0e99f68e5eaa3918b8827ca8097aeeb17146 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:54:28 +0100 Subject: [PATCH 144/349] refactor(input): Extract ChatInputCallbacks and pass ChatInputUiState directly --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 77 ++++++++++--------- .../dankchat/ui/main/input/ChatBottomBar.kt | 47 ++--------- .../dankchat/ui/main/input/ChatInputLayout.kt | 64 +++++++++------ 3 files changed, 89 insertions(+), 99 deletions(-) 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 index faa65d43e..ea5b3f19a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -115,8 +115,8 @@ 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.CharacterCounterState import com.flxrs.dankchat.ui.main.input.ChatBottomBar +import com.flxrs.dankchat.ui.main.input.ChatInputCallbacks import com.flxrs.dankchat.ui.main.input.InputOverlay import com.flxrs.dankchat.ui.main.input.ChatInputViewModel import com.flxrs.dankchat.ui.main.input.SuggestionDropdown @@ -579,7 +579,43 @@ fun MainScreen( ChatBottomBar( showInput = effectiveShowInput && !isHistorySheet, textFieldState = chatInputViewModel.textFieldState, - inputState = inputState, + 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, + onToggleStream = { + when { + currentStream != null -> streamViewModel.closeStream() + else -> activeChannel?.let { streamViewModel.toggleStream(it) } + } + }, + onModActions = dialogViewModel::showModActions, + onInputActionsChanged = mainScreenViewModel::updateInputActions, + onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, + onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, + onNewWhisper = if (inputState.isWhisperTabActive) { + dialogViewModel::showNewWhisper + } else null, + onRepeatedSendChanged = chatInputViewModel::setRepeatedSend, + ), isUploading = dialogState.isUploading, isLoading = tabState.loading, isFullscreen = isFullscreen, @@ -598,49 +634,14 @@ fun MainScreen( is FullScreenSheetState.History, is FullScreenSheetState.Closed -> mainState.inputActions }, - characterCounter = if (mainState.showCharacterCounter) inputState.characterCounter else CharacterCounterState.Hidden, - 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, - onToggleStream = { - when { - currentStream != null -> streamViewModel.closeStream() - else -> activeChannel?.let { streamViewModel.toggleStream(it) } - } - }, - onModActions = dialogViewModel::showModActions, - onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, - onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, + onInputHeightChanged = { inputHeightPx = it }, debugMode = mainState.debugMode, - onNewWhisper = if (inputState.isWhisperTabActive) { - dialogViewModel::showNewWhisper - } else null, - onInputActionsChanged = mainScreenViewModel::updateInputActions, overflowExpanded = inputOverflowExpanded, onOverflowExpandedChanged = { inputOverflowExpanded = it }, - onInputHeightChanged = { inputHeightPx = it }, onHelperTextHeightChanged = { helperTextHeightPx = it }, isInSplitLayout = useWideSplitLayout, instantHide = isHistorySheet, isRepeatedSendEnabled = mainState.isRepeatedSendEnabled, - onRepeatedSendChanged = chatInputViewModel::setRepeatedSend, tourState = remember(featureTourState.currentTourStep, featureTourState.forceOverflowOpen, featureTourState.isTourActive) { TourOverlayState( inputActionsTooltipState = if (featureTourState.currentTourStep == TourStep.InputActions) featureTourViewModel.inputActionsTooltipState else null, 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 index e30dd3ae3..a80b6f650 100644 --- 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 @@ -32,7 +32,8 @@ import kotlinx.collections.immutable.ImmutableList fun ChatBottomBar( showInput: Boolean, textFieldState: TextFieldState, - inputState: ChatInputUiState, + uiState: ChatInputUiState, + callbacks: ChatInputCallbacks, isUploading: Boolean, isLoading: Boolean, isFullscreen: Boolean, @@ -41,29 +42,16 @@ fun ChatBottomBar( hasStreamData: Boolean, isSheetOpen: Boolean, inputActions: ImmutableList, - characterCounter: CharacterCounterState = CharacterCounterState.Hidden, - onSend: () -> Unit, - onLastMessageClick: () -> Unit, - onEmoteClick: () -> Unit, - onOverlayDismiss: () -> Unit, - onToggleFullscreen: () -> Unit, - onToggleInput: () -> Unit, - onToggleStream: () -> Unit, - onModActions: () -> Unit, - onSearchClick: () -> Unit, - onDebugInfoClick: () -> Unit = {}, + onInputHeightChanged: (Int) -> Unit, + modifier: Modifier = Modifier, debugMode: Boolean = false, - onNewWhisper: (() -> Unit)?, - onInputActionsChanged: (ImmutableList) -> Unit, overflowExpanded: Boolean = false, onOverflowExpandedChanged: (Boolean) -> Unit = {}, - onInputHeightChanged: (Int) -> Unit, onHelperTextHeightChanged: (Int) -> Unit = {}, isInSplitLayout: Boolean = false, instantHide: Boolean = false, tourState: TourOverlayState = TourOverlayState(), isRepeatedSendEnabled: Boolean = false, - onRepeatedSendChanged: (Boolean) -> Unit = {}, ) { Column(modifier = Modifier.fillMaxWidth()) { AnimatedVisibility( @@ -76,12 +64,9 @@ fun ChatBottomBar( ) { ChatInputLayout( textFieldState = textFieldState, - inputState = inputState.inputState, - enabled = inputState.enabled, - hasLastMessage = inputState.hasLastMessage, - canSend = inputState.canSend, - isEmoteMenuOpen = inputState.isEmoteMenuOpen, - helperText = if (isSheetOpen) null else inputState.helperText, + uiState = uiState, + callbacks = callbacks, + isSheetOpen = isSheetOpen, isUploading = isUploading, isLoading = isLoading, isFullscreen = isFullscreen, @@ -89,27 +74,11 @@ fun ChatBottomBar( isStreamActive = isStreamActive, hasStreamData = hasStreamData, inputActions = inputActions, - characterCounter = characterCounter, - onSend = onSend, - onLastMessageClick = onLastMessageClick, - onEmoteClick = onEmoteClick, - overlay = inputState.overlay, - onOverlayDismiss = onOverlayDismiss, - onToggleFullscreen = onToggleFullscreen, - onToggleInput = onToggleInput, - onToggleStream = onToggleStream, - onModActions = onModActions, - onInputActionsChanged = onInputActionsChanged, - onSearchClick = onSearchClick, - onDebugInfoClick = onDebugInfoClick, debugMode = debugMode, - onNewWhisper = onNewWhisper, overflowExpanded = overflowExpanded, onOverflowExpandedChanged = onOverflowExpandedChanged, - showQuickActions = !isSheetOpen, tourState = tourState, isRepeatedSendEnabled = isRepeatedSendEnabled, - onRepeatedSendChanged = onRepeatedSendChanged, modifier = Modifier.onGloballyPositioned { coordinates -> onInputHeightChanged(coordinates.size.height) } @@ -118,7 +87,7 @@ fun ChatBottomBar( // Sticky helper text + nav bar spacer when input is hidden if (!showInput && !isSheetOpen) { - val helperText = inputState.helperText + val helperText = uiState.helperText if (!helperText.isNullOrEmpty()) { val horizontalPadding = when { isFullscreen && isInSplitLayout -> { 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 index c60c38d08..d8a9ba920 100644 --- 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 @@ -118,15 +118,28 @@ data class TourOverlayState( val onSkip: (() -> Unit)? = null, ) +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 onModActions: () -> Unit, + val onInputActionsChanged: (ImmutableList) -> Unit, + val onSearchClick: () -> Unit = {}, + val onDebugInfoClick: () -> Unit = {}, + val onNewWhisper: (() -> Unit)? = null, + val onRepeatedSendChanged: (Boolean) -> Unit = {}, +) + @Composable fun ChatInputLayout( textFieldState: TextFieldState, - inputState: InputState, - enabled: Boolean, - hasLastMessage: Boolean, - canSend: Boolean, - isEmoteMenuOpen: Boolean, - helperText: String?, + uiState: ChatInputUiState, + callbacks: ChatInputCallbacks, + isSheetOpen: Boolean, isUploading: Boolean, isLoading: Boolean, isFullscreen: Boolean, @@ -135,28 +148,35 @@ fun ChatInputLayout( hasStreamData: Boolean, inputActions: ImmutableList, modifier: Modifier = Modifier, - characterCounter: CharacterCounterState = CharacterCounterState.Hidden, - onSend: () -> Unit, - onLastMessageClick: () -> Unit, - onEmoteClick: () -> Unit, - onToggleFullscreen: () -> Unit, - onToggleInput: () -> Unit, - onToggleStream: () -> Unit, - overlay: InputOverlay, - onOverlayDismiss: () -> Unit, - onModActions: () -> Unit, - onInputActionsChanged: (ImmutableList) -> Unit, - onSearchClick: () -> Unit = {}, - onDebugInfoClick: () -> Unit = {}, debugMode: Boolean = false, - onNewWhisper: (() -> Unit)? = null, overflowExpanded: Boolean = false, onOverflowExpandedChanged: (Boolean) -> Unit = {}, - showQuickActions: Boolean = true, tourState: TourOverlayState = TourOverlayState(), isRepeatedSendEnabled: Boolean = false, - onRepeatedSendChanged: (Boolean) -> Unit = {}, ) { + val inputState = uiState.inputState + val enabled = uiState.enabled + val hasLastMessage = uiState.hasLastMessage + val canSend = uiState.canSend + val isEmoteMenuOpen = uiState.isEmoteMenuOpen + val helperText = if (isSheetOpen) null 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 onInputActionsChanged = callbacks.onInputActionsChanged + val onSearchClick = callbacks.onSearchClick + val onDebugInfoClick = callbacks.onDebugInfoClick + val onNewWhisper = callbacks.onNewWhisper + val onRepeatedSendChanged = callbacks.onRepeatedSendChanged + val focusRequester = remember { FocusRequester() } val hint = when (inputState) { InputState.Default -> stringResource(R.string.hint_connected) From 587499a49048bbb1baa4dfffac102d870e721927 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 15:57:43 +0100 Subject: [PATCH 145/349] refactor(ui): Move upload disclaimer and whisper dialogs from MainScreen into MainScreenDialogs --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 62 ----------------- .../ui/main/dialog/DialogStateViewModel.kt | 4 ++ .../ui/main/dialog/MainScreenDialogs.kt | 66 +++++++++++++++++++ 3 files changed, 70 insertions(+), 62 deletions(-) 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 index ea5b3f19a..1e6ad9d68 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LinearProgressIndicator @@ -363,67 +362,6 @@ fun MainScreen( }, ) - // External hosting upload disclaimer dialog - if (dialogState.pendingUploadAction != null) { - AlertDialog( - onDismissRequest = { dialogViewModel.setPendingUploadAction(null) }, - title = { Text(stringResource(R.string.nuuls_upload_title)) }, - text = { Text(stringResource(R.string.external_upload_disclaimer, dialogViewModel.uploadHost)) }, - confirmButton = { - TextButton( - onClick = { - preferenceStore.hasExternalHostingAcknowledged = true - val action = dialogState.pendingUploadAction - dialogViewModel.setPendingUploadAction(null) - action?.invoke() - } - ) { - Text(stringResource(R.string.dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = { dialogViewModel.setPendingUploadAction(null) }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - - // New Whisper dialog - if (dialogState.showNewWhisper) { - var whisperUsername by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = dialogViewModel::dismissNewWhisper, - title = { Text(stringResource(R.string.whisper_new_dialog_title)) }, - text = { - OutlinedTextField( - value = whisperUsername, - onValueChange = { whisperUsername = it }, - label = { Text(stringResource(R.string.whisper_new_dialog_hint)) }, - singleLine = true, - ) - }, - confirmButton = { - TextButton( - onClick = { - val username = whisperUsername.trim() - if (username.isNotBlank()) { - chatInputViewModel.setWhisperTarget(UserName(username)) - dialogViewModel.dismissNewWhisper() - } - } - ) { - Text(stringResource(R.string.whisper_new_dialog_start)) - } - }, - dismissButton = { - TextButton(onClick = dialogViewModel::dismissNewWhisper) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } - val isFullscreen = mainState.isFullscreen val effectiveShowInput = mainState.effectiveShowInput val effectiveShowAppBar = mainState.effectiveShowAppBar 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 index 2d646fef3..b6ce15af8 100644 --- 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 @@ -92,6 +92,10 @@ class DialogStateViewModel( update { copy(pendingUploadAction = action) } } + fun acknowledgeExternalHosting() { + preferenceStore.hasExternalHostingAcknowledged = true + } + fun setUploading(uploading: Boolean) { update { copy(isUploading = uploading) } } 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 index e33466996..00dc2a086 100644 --- 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 @@ -1,12 +1,19 @@ package com.flxrs.dankchat.ui.main.dialog import android.content.ClipData +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource @@ -138,6 +145,65 @@ fun MainScreenDialogs( ) } + if (dialogState.pendingUploadAction != null) { + AlertDialog( + onDismissRequest = { dialogViewModel.setPendingUploadAction(null) }, + title = { Text(stringResource(R.string.nuuls_upload_title)) }, + text = { Text(stringResource(R.string.external_upload_disclaimer, dialogViewModel.uploadHost)) }, + confirmButton = { + TextButton( + onClick = { + dialogViewModel.acknowledgeExternalHosting() + val action = dialogState.pendingUploadAction + dialogViewModel.setPendingUploadAction(null) + action?.invoke() + } + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { dialogViewModel.setPendingUploadAction(null) }) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + + if (dialogState.showNewWhisper) { + var whisperUsername by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = dialogViewModel::dismissNewWhisper, + title = { Text(stringResource(R.string.whisper_new_dialog_title)) }, + text = { + OutlinedTextField( + value = whisperUsername, + onValueChange = { whisperUsername = it }, + label = { Text(stringResource(R.string.whisper_new_dialog_hint)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { + val username = whisperUsername.trim() + if (username.isNotBlank()) { + chatInputViewModel.setWhisperTarget(UserName(username)) + dialogViewModel.dismissNewWhisper() + } + } + ) { + Text(stringResource(R.string.whisper_new_dialog_start)) + } + }, + dismissButton = { + TextButton(onClick = dialogViewModel::dismissNewWhisper) { + Text(stringResource(R.string.dialog_cancel)) + } + } + ) + } + if (startupValidation is StartupValidation.ScopesOutdated) { InfoBottomSheet( title = stringResource(R.string.login_outdated_title), From e0e361eabf380952fc328cf5942a930357aa6a9b Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 17:45:14 +0100 Subject: [PATCH 146/349] feat(developer): Add token revocation option, categorize developer settings, add translations --- .../flxrs/dankchat/data/api/auth/AuthApi.kt | 10 ++ .../dankchat/data/api/auth/AuthApiClient.kt | 4 + .../developer/DeveloperSettingsScreen.kt | 95 +++++++++++-------- .../developer/DeveloperSettingsViewModel.kt | 14 +++ .../main/res/values-b+zh+Hant+TW/strings.xml | 10 ++ app/src/main/res/values-be-rBY/strings.xml | 10 ++ app/src/main/res/values-ca/strings.xml | 10 ++ app/src/main/res/values-cs/strings.xml | 10 ++ app/src/main/res/values-de-rDE/strings.xml | 10 ++ app/src/main/res/values-en-rAU/strings.xml | 10 ++ app/src/main/res/values-en-rGB/strings.xml | 10 ++ app/src/main/res/values-en/strings.xml | 10 ++ app/src/main/res/values-es-rES/strings.xml | 10 ++ app/src/main/res/values-fi-rFI/strings.xml | 10 ++ app/src/main/res/values-fr-rFR/strings.xml | 10 ++ app/src/main/res/values-hu-rHU/strings.xml | 10 ++ app/src/main/res/values-it/strings.xml | 10 ++ app/src/main/res/values-ja-rJP/strings.xml | 10 ++ app/src/main/res/values-kk-rKZ/strings.xml | 10 ++ app/src/main/res/values-or-rIN/strings.xml | 10 ++ app/src/main/res/values-pl-rPL/strings.xml | 10 ++ app/src/main/res/values-pt-rBR/strings.xml | 10 ++ app/src/main/res/values-pt-rPT/strings.xml | 10 ++ app/src/main/res/values-ru-rRU/strings.xml | 10 ++ app/src/main/res/values-sr/strings.xml | 10 ++ app/src/main/res/values-tr-rTR/strings.xml | 10 ++ app/src/main/res/values-uk-rUA/strings.xml | 10 ++ app/src/main/res/values/strings.xml | 9 ++ 28 files changed, 320 insertions(+), 42 deletions(-) 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..53256c193 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,21 @@ 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) { suspend fun validateUser(token: String) = ktorClient.get("validate") { bearerAuth(token) } + + suspend fun revokeToken(token: String, clientId: String) = ktorClient.submitForm( + url = "revoke", + formParameters = parameters { + append("client_id", clientId) + append("token", token) + } + ) } \ No newline at end of file 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 d371a31f4..57845b3c2 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 @@ -26,6 +26,10 @@ class AuthApiClient(private val authApi: AuthApi, private val json: Json) { } } + 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() 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 index 857d9d4ed..e78e12c09 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -98,7 +98,7 @@ fun DeveloperSettingsScreen( LaunchedEffect(viewModel) { viewModel.events.collectLatest { when (it) { - DeveloperSettingsEvent.RestartRequired -> { + DeveloperSettingsEvent.RestartRequired -> { val result = snackbarHostState.showSnackbar( message = restartRequiredTitle, actionLabel = restartRequiredAction, @@ -108,6 +108,10 @@ fun DeveloperSettingsScreen( ProcessPhoenix.triggerRebirth(context) } } + + DeveloperSettingsEvent.ImmediateRestart -> { + ProcessPhoenix.triggerRebirth(context) + } } } } @@ -152,44 +156,37 @@ private fun DeveloperSettingsContent( .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) - } + PreferenceCategory(title = stringResource(R.string.preference_developer_category_general)) { + 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)) }, ) - } - ExpandablePreferenceItem(title = stringResource(R.string.preference_rm_host_title)) { - CustomRecentMessagesHostBottomSheet( - initialHost = settings.customRecentMessagesHost, - onInteraction = { - dismiss() - onInteraction(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)) }, ) + ExpandablePreferenceItem(title = stringResource(R.string.preference_rm_host_title)) { + CustomRecentMessagesHostBottomSheet( + initialHost = settings.customRecentMessagesHost, + onInteraction = { + dismiss() + onInteraction(it) + }, + ) + } } - PreferenceCategory(title = stringResource(R.string.preference_chat_send_protocol_category)) { + 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), @@ -202,26 +199,40 @@ private fun DeveloperSettingsContent( onInteraction(DeveloperSettingsInteraction.ChatSendProtocolChanged(protocol)) }, ) - } - - PreferenceCategory(title = "EventSub") { if (!settings.isPubSubShutdown) { SwitchPreferenceItem( - title = "Enable Twitch EventSub", - summary = "Uses EventSub for various real-time events instead of deprecated PubSub", + title = stringResource(R.string.preference_eventsub_title), + summary = stringResource(R.string.preference_eventsub_summary), isChecked = settings.shouldUseEventSub, onClick = { onInteraction(EventSubEnabled(it)) }, ) } SwitchPreferenceItem( - title = "Enable EventSub debug output", - summary = "Prints debug output related to EventSub as system messages", + 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( + onDismissRequested = ::dismiss, + onRestartRequiredRequested = { + 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), 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 7cc19cafd..ffe2f87a3 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,9 +2,12 @@ package com.flxrs.dankchat.preferences.developer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.api.auth.AuthApiClient +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.preferences.DankChatPreferenceStore 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 @@ -20,6 +23,8 @@ 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() @@ -78,6 +83,13 @@ class DeveloperSettingsViewModel( } _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) + } } } } @@ -85,6 +97,7 @@ class DeveloperSettingsViewModel( sealed interface DeveloperSettingsEvent { data object RestartRequired : DeveloperSettingsEvent + data object ImmediateRestart : DeveloperSettingsEvent } sealed interface DeveloperSettingsInteraction { @@ -98,6 +111,7 @@ sealed interface DeveloperSettingsInteraction { data object RestartRequired : DeveloperSettingsInteraction data object ResetOnboarding : DeveloperSettingsInteraction data object ResetTour : DeveloperSettingsInteraction + data object RevokeToken : DeveloperSettingsInteraction } 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 0f2ff5cb8..6f3756cba 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 @@ -643,4 +643,14 @@ 了解 略過導覽 您可以在此新增更多頻道 + + + 一般 + 驗證 + 啟用 Twitch EventSub + 使用 EventSub 取代已棄用的 PubSub 來接收各種即時事件 + 啟用 EventSub 除錯輸出 + 將 EventSub 相關除錯資訊以系統訊息顯示 + 撤銷權杖並重新啟動 + 使目前的權杖失效並重新啟動應用程式 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 64e12c90f..f40b1d0af 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -660,4 +660,14 @@ Працягнуць Пачаць Прапусціць + + + Агульныя + Аўтарызацыя + Уключыць Twitch EventSub + Выкарыстоўвае EventSub для розных падзей у рэальным часе замест састарэлага PubSub + Уключыць адладачны вывад EventSub + Выводзіць адладачную інфармацыю пра EventSub як сістэмныя паведамленні + Адклікаць токен і перазапусціць + Робіць бягучы токен несапраўдным і перазапускае праграму diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 911304cca..26fb5b3c9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -686,4 +686,14 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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ó diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 24d8d66e3..aa1ad62b6 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -661,4 +661,14 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 312888b99..9435bc319 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -659,4 +659,14 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index ce8a6197f..5515e2d5f 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -638,4 +638,14 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 878caaae5..25e6f952b 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -638,4 +638,14 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7cfee10de..3f7b7853f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -653,4 +653,14 @@ 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index f12cc2097..0b68f7bcd 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -669,4 +669,14 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 059c1f160..5a81bcc6c 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -661,4 +661,14 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 7609b3bc2..39f65057b 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -653,4 +653,14 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index b60e16310..618168bd0 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -637,4 +637,14 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b1a58940e..e4a2f8ce3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -636,4 +636,14 @@ 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 8107d2b91..2984949b6 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -617,4 +617,14 @@ 続ける 始める スキップ + + + 一般 + 認証 + Twitch EventSubを有効にする + 非推奨のPubSubの代わりにEventSubを使用してリアルタイムイベントを処理します + EventSubデバッグ出力を有効にする + EventSub関連のデバッグ情報をシステムメッセージとして表示します + トークンを失効させて再起動 + 現在のトークンを無効化してアプリを再起動します diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 08bd737f5..8349d2401 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -642,4 +642,14 @@ Түсіндім Турды өткізу Мұнда қосымша арналар қосуға болады + + + Жалпы + Аутентификация + Twitch EventSub қосу + Ескірген PubSub орнына нақты уақыттағы оқиғалар үшін EventSub пайдаланады + EventSub жөндеу шығысын қосу + EventSubқа қатысты жөндеу ақпаратын жүйелік хабарлар ретінде көрсетеді + Токенді қайтарып алу және қайта іске қосу + Ағымдағы токенді жарамсыз етіп, қолданбаны қайта іске қосады diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index b5b5192b9..94c600c9a 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -642,4 +642,14 @@ ବୁଝିଗଲି ଟୁର୍ ଛାଡି ଦିଅନ୍ତୁ ଆପଣ ଏଠାରେ ଅଧିକ ଚ୍ୟାନେଲ ଯୋଡିପାରିବେ + + + ସାଧାରଣ + ପ୍ରମାଣୀକରଣ + Twitch EventSub ସକ୍ରିୟ କରନ୍ତୁ + ଅଚଳ PubSub ବଦଳରେ ବିଭିନ୍ନ ରିଅଲ-ଟାଇମ ଇଭେଣ୍ଟ ପାଇଁ EventSub ବ୍ୟବହାର କରେ + EventSub ଡିବଗ୍ ଆଉଟପୁଟ୍ ସକ୍ରିୟ କରନ୍ତୁ + EventSub ସମ୍ବନ୍ଧୀୟ ଡିବଗ୍ ତଥ୍ୟ ସିଷ୍ଟମ ମେସେଜ୍ ଭାବରେ ଦେଖାଏ + ଟୋକେନ୍ ବାତିଲ କରନ୍ତୁ ଏବଂ ପୁନଃଆରମ୍ଭ କରନ୍ତୁ + ବର୍ତ୍ତମାନର ଟୋକେନ୍ ଅବୈଧ କରି ଆପ୍ ପୁନଃଆରମ୍ଭ କରେ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 7728d17f6..c04c2201c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -679,4 +679,14 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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ę diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 995190367..4b1c1ee20 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -648,4 +648,14 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 8cab0ad5d..f05645f55 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -638,4 +638,14 @@ 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3ea55b6fa..e6eca65aa 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -665,4 +665,14 @@ Продолжить Начать Пропустить + + + Общие + Авторизация + Включить Twitch EventSub + Использует EventSub для различных событий в реальном времени вместо устаревшего PubSub + Включить отладочный вывод EventSub + Выводит отладочную информацию по EventSub в виде системных сообщений + Отозвать токен и перезапустить + Аннулирует текущий токен и перезапускает приложение diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 7f5ac4a97..d6b88b924 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -690,4 +690,14 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Разумем Прескочи обилазак Овде можете додати више канала + + + Опште + Аутентификација + Укључи Twitch EventSub + Користи EventSub за различите догађаје у реалном времену уместо застарелог PubSub + Укључи дебаг излаз за EventSub + Приказује дебаг излаз везан за EventSub као системске поруке + Опозови токен и рестартуј + Поништава тренутни токен и рестартује апликацију diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 62eb5696c..ddb5844ba 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -658,4 +658,14 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 7884ba920..8e83236c7 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -662,4 +662,14 @@ Продовжити Почати Пропустити + + + Загальне + Авторизація + Увімкнути Twitch EventSub + Використовує EventSub для різних подій у реальному часі замість застарілого PubSub + Увімкнути налагоджувальний вивід EventSub + Виводить налагоджувальну інформацію щодо EventSub як системні повідомлення + Відкликати токен і перезапустити + Анулює поточний токен і перезапускає застосунок diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ffcf621ed..d867e1a36 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -505,6 +505,15 @@ 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 From 4fd6bd5e3519df4223a8050484e9d1ffbd0a126d Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 17:59:27 +0100 Subject: [PATCH 147/349] fix(auth): Use closeAndReconnect on re-login to handle connections that were never initialized --- .../kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index df6844908..23d6978f9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -63,7 +63,7 @@ class AuthStateCoordinator( when { settings.isLoggedIn -> { startupValidationHolder.update(StartupValidation.Validated) - chatConnector.reconnect() + chatConnector.closeAndReconnect(chatChannelProvider.channels.value.orEmpty()) channelDataCoordinator.reloadGlobalData() settings.userName?.let { name -> _events.send(AuthEvent.LoggedIn(UserName(name))) From 05b711c72f817b876ae73870d9a3082ed63d0f71 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 19:22:03 +0100 Subject: [PATCH 148/349] refactor(connection): Extract reconnection logic into ConnectionCoordinator, convert ChatConnection.connected to StateFlow --- .../com/flxrs/dankchat/DankChatApplication.kt | 4 ++ .../com/flxrs/dankchat/DankChatViewModel.kt | 32 +---------- .../data/debug/ConnectionDebugSection.kt | 13 ++++- .../dankchat/data/repo/chat/ChatConnector.kt | 2 +- .../data/twitch/chat/ChatConnection.kt | 35 ++++++------ .../dankchat/domain/ConnectionCoordinator.kt | 57 +++++++++++++++++++ .../flxrs/dankchat/ui/main/MainActivity.kt | 4 -- 7 files changed, 94 insertions(+), 53 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 2cda437f9..e75ca0dbe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -14,6 +14,7 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory import com.flxrs.dankchat.data.repo.HighlightsRepository import com.flxrs.dankchat.data.repo.IgnoresRepository import com.flxrs.dankchat.di.DankChatModule +import com.flxrs.dankchat.domain.ConnectionCoordinator import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.appearance.ThemePreference.Dark @@ -40,6 +41,7 @@ class DankChatApplication : Application(), SingletonImageLoader.Factory { private val highlightsRepository: HighlightsRepository by inject() private val ignoresRepository: IgnoresRepository by inject() private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() + private val connectionCoordinator: ConnectionCoordinator by inject() override fun onCreate() { super.onCreate() @@ -48,6 +50,8 @@ class DankChatApplication : Application(), SingletonImageLoader.Factory { modules(DankChatModule().module) } + connectionCoordinator.initialize() + scope.launch(dispatchersProvider.immediate) { setupThemeMode() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 1b3e61804..ec976fa95 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -3,10 +3,8 @@ package com.flxrs.dankchat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.auth.AuthDataStore -import com.flxrs.dankchat.data.auth.AuthEvent import com.flxrs.dankchat.data.auth.AuthStateCoordinator 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.preferences.appearance.AppearanceSettingsDataStore import kotlinx.coroutines.flow.Flow @@ -15,23 +13,19 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class DankChatViewModel( - private val chatChannelProvider: ChatChannelProvider, - private val chatConnector: ChatConnector, private val authDataStore: AuthDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val dataRepository: DataRepository, + private val chatChannelProvider: ChatChannelProvider, private val authStateCoordinator: AuthStateCoordinator, ) : ViewModel() { val serviceEvents = dataRepository.serviceEvents - private var initialConnectionStarted = false - val activeChannel = chatChannelProvider.activeChannel val isLoggedIn: Flow = authDataStore.settings .map { it.isLoggedIn } @@ -46,30 +40,6 @@ class DankChatViewModel( initialValue = appearanceSettingsDataStore.current().keepScreenOn, ) - init { - viewModelScope.launch { - val result = authStateCoordinator.validateOnStartup() - when (result) { - // Don't connect with an invalid token — the logout/re-login flow - // triggers closeAndReconnect via the AuthStateCoordinator settings observer. - is AuthEvent.TokenInvalid -> return@launch - else -> Unit - } - - initialConnectionStarted = true - chatConnector.connectAndJoin(chatChannelProvider.channels.value.orEmpty()) - } - } - - fun reconnectIfNecessary() { - if (!initialConnectionStarted) return - - viewModelScope.launch { - chatConnector.reconnectIfNecessary() - dataRepository.reconnectIfNecessary() - } - } - fun checkLogin() { if (authDataStore.isLoggedIn && authDataStore.oAuthKey.isNullOrBlank()) { authStateCoordinator.logout() 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 index 45e2bc3a9..d9d4ceb58 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt @@ -3,15 +3,21 @@ 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.ReadConnection +import com.flxrs.dankchat.di.WriteConnection 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(type = ReadConnection::class) private val readConnection: ChatConnection, + @Named(type = WriteConnection::class) private val writeConnection: ChatConnection, private val eventSubClient: EventSubClient, private val pubSubManager: PubSubManager, private val sevenTVEventApiClient: SevenTVEventApiClient, @@ -27,7 +33,7 @@ class ConnectionDebugSection( delay(2_000) } } - return combine(eventSubClient.state, eventSubClient.topics, ticker) { state, topics, _ -> + 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" @@ -47,9 +53,14 @@ class ConnectionDebugSection( 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}"), 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 index e482b3618..79ef1a755 100644 --- 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 @@ -56,7 +56,7 @@ class ChatConnector( } fun connectAndJoin(channels: List) { - if (!readConnection.connected) { + if (!readConnection.connected.value) { readConnection.connect() writeConnection.connect() 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 1056f2eee..a9be01ddc 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 @@ -15,6 +15,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob 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 @@ -84,8 +87,8 @@ 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 @@ -94,7 +97,7 @@ class ChatConnection( init { scope.launch { channelsToJoin.consumeAsFlow().collect { channelsToJoin -> - if (!connected) return@collect + if (!_connected.value) return@collect channelsToJoin.filter { it in channels } .chunked(JOIN_CHUNK_SIZE) @@ -109,7 +112,7 @@ class ChatConnection( } fun sendMessage(msg: String) { - if (connected) { + if (_connected.value) { socket?.sendMessage(msg) } } @@ -118,7 +121,7 @@ class ChatConnection( val newChannels = channelList - channels channels.addAll(newChannels) - if (connected) { + if (_connected.value) { scope.launch { channelsToJoin.send(newChannels) } @@ -129,7 +132,7 @@ class ChatConnection( if (channel in channels) return channels += channel - if (connected) { + if (_connected.value) { scope.launch { channelsToJoin.send(listOf(channel)) } @@ -140,13 +143,13 @@ class ChatConnection( if (channel !in channels) return channels.remove(channel) - if (connected) { + if (_connected.value) { socket?.sendMessage("PART #$channel") } } fun connect() { - if (connected || connecting) return + if (_connected.value || connecting) return currentUserName = authDataStore.userName currentOAuth = authDataStore.oAuthKey @@ -156,7 +159,7 @@ class ChatConnection( } fun close() { - connected = false + _connected.value = false socket?.close(1000, null) ?: socket?.cancel() } @@ -166,7 +169,7 @@ class ChatConnection( } fun reconnectIfNecessary() { - if (connected || connecting) return + if (_connected.value || connecting) return reconnect() } @@ -188,7 +191,7 @@ class ChatConnection( return@timer } - if (connected) { + if (_connected.value) { awaitingPong = true webSocket.sendMessage("PING") } @@ -197,12 +200,12 @@ class ChatConnection( 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()) { + if (socket == null || !_connected.value || channelsAttemptedToJoin.isEmpty()) { return@launch } delay(JOIN_CHECK_DELAY) - if (socket == null || !connected) { + if (socket == null || !_connected.value) { channelsAttemptedToJoin.removeAll(channelsToCheck.toSet()) return@launch } @@ -219,7 +222,7 @@ class ChatConnection( private var pingJob: Job? = null override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - connected = false + _connected.value = false pingJob?.cancel() channelsAttemptedToJoin.clear() scope.launch { receiveChannel.send(ChatEvent.Closed) } @@ -228,7 +231,7 @@ class ChatConnection( 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 + _connected.value = false connecting = false pingJob?.cancel() channelsAttemptedToJoin.clear() @@ -238,7 +241,7 @@ class ChatConnection( } override fun onOpen(webSocket: WebSocket, response: Response) { - connected = true + _connected.value = true connecting = false reconnectAttempts = 1 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..0187f29e0 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt @@ -0,0 +1,57 @@ +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/ui/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt index d648ef7ca..114cea7d8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -512,10 +512,6 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() - if (!isChangingConfigurations) { - viewModel.reconnectIfNecessary() - } - val hasCompletedOnboarding = onboardingDataStore.current().hasCompletedOnboarding val needsNotificationPermission = hasCompletedOnboarding && isAtLeastTiramisu && !hasPermission(Manifest.permission.POST_NOTIFICATIONS) when { From 3659bae4f0a04f03e8741c8e7ad77303c38bbec0 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 28 Mar 2026 21:55:28 +0100 Subject: [PATCH 149/349] refactor(websocket): Migrate ChatConnection and PubSubConnection from OkHttp to Ktor WebSockets --- app/build.gradle.kts | 16 + .../dankchat/data/repo/chat/ChatConnector.kt | 4 +- .../data/repo/chat/ChatMessageSender.kt | 2 +- .../data/twitch/chat/ChatConnection.kt | 299 ++++++++------- .../data/twitch/pubsub/PubSubConnection.kt | 360 ++++++++++-------- .../data/twitch/pubsub/PubSubManager.kt | 24 +- .../com/flxrs/dankchat/di/ConnectionModule.kt | 10 +- .../extensions/SystemMessageOperations.kt | 20 +- .../data/twitch/chat/MockIrcServer.kt | 89 +++++ .../twitch/chat/TwitchIrcIntegrationTest.kt | 225 +++++++++++ .../ui/tour/FeatureTourViewModelTest.kt | 5 +- gradle/libs.versions.toml | 1 + 12 files changed, 741 insertions(+), 314 deletions(-) create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/MockIrcServer.kt create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/TwitchIrcIntegrationTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23955a56c..9ef7a6fc6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,6 +53,10 @@ android { } } + testOptions { + unitTests.isReturnDefaultValues = true + } + buildTypes { getByName("release") { isMinifyEnabled = true @@ -205,6 +209,7 @@ dependencies { 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 @@ -221,6 +226,17 @@ dependencies { 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) +} + +junitPlatform { + filters { + if (!project.hasProperty("includeIntegration")) { + excludeTags("integration") + } + } } fun gradleLocalProperties(projectRootDir: File, providers: ProviderFactory): Properties { 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 index 79ef1a755..6f63d7e94 100644 --- 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 @@ -95,12 +95,12 @@ class ChatConnector( } fun partChannel(channel: UserName) { - readConnection.partChannel(channel) + scope.launch { readConnection.partChannel(channel) } pubSubManager.removeChannel(channel) eventSubManager.removeChannel(channel) } - fun sendRaw(message: String) { + suspend fun sendRaw(message: String) { writeConnection.sendMessage(message) } 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 index 532493a0e..29466ec1e 100644 --- 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 @@ -37,7 +37,7 @@ class ChatMessageSender( } } - private fun sendViaIrc(channel: UserName, message: String, replyId: String?) { + 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() 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 a9be01ddc..8b70c6b2f 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,18 +1,26 @@ 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.utils.extensions.timer -import io.ktor.http.HttpHeaders +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 @@ -21,15 +29,10 @@ 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 @@ -52,32 +55,26 @@ sealed interface ChatEvent { get() = this is Error || this is Closed } +@OptIn(DelicateCoroutinesApi::class) class ChatConnection( private val chatConnectionType: ChatConnectionType, - private val client: OkHttpClient, + 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() @@ -98,11 +95,12 @@ class ChatConnection( scope.launch { channelsToJoin.consumeAsFlow().collect { channelsToJoin -> if (!_connected.value) return@collect + val currentSession = session ?: return@collect 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) @@ -111,10 +109,10 @@ class ChatConnection( } } - fun sendMessage(msg: String) { - if (_connected.value) { - socket?.sendMessage(msg) - } + suspend fun sendMessage(msg: String) { + val currentSession = session ?: return + if (!_connected.value) return + currentSession.sendIrc(msg) } fun joinChannels(channelList: List) { @@ -139,53 +137,162 @@ class ChatConnection( } } - fun partChannel(channel: UserName) { + suspend fun partChannel(channel: UserName) { if (channel !in channels) return channels.remove(channel) if (_connected.value) { - socket?.sendMessage("PART #$channel") + val currentSession = session ?: return + currentSession.sendIrc("PART #$channel") } } fun connect() { - if (_connected.value || connecting) return + if (session?.isActive == true) return + connectionJob?.cancel() 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()) { + Log.e(TAG, "[$chatConnectionType] authentication failed with expired token, closing connection..") + receiveChannel.send(ChatEvent.LoginFailed) + return@webSocket + } + + when (ircMessage.command) { + "376" -> { + Log.i(TAG, "[$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)) { + Log.i(TAG, "[$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" -> { + Log.i(TAG, "[$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) { + Log.i(TAG, "[$chatConnectionType] connection closed") + return@launch + } + Log.i(TAG, "[$chatConnectionType] reconnecting after server request") + + } catch (t: Throwable) { + if (t is CancellationException) throw t + + Log.e(TAG, "[$chatConnectionType] connection failed: $t") + Log.e(TAG, "[$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) + } + } + + Log.e(TAG, "[$chatConnectionType] connection failed after $RECONNECT_MAX_ATTEMPTS retries") + _connected.value = false + session = null + } } fun close() { _connected.value = false - socket?.close(1000, null) ?: socket?.cancel() + 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.value || 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 @@ -193,19 +300,18 @@ class ChatConnection( 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.value || channelsAttemptedToJoin.isEmpty()) { + if (session?.isActive != true || !_connected.value || channelsAttemptedToJoin.isEmpty()) { return@launch } delay(JOIN_CHECK_DELAY) - if (socket == null || !_connected.value) { + if (session?.isActive != true || !_connected.value) { channelsAttemptedToJoin.removeAll(channelsToCheck.toSet()) return@launch } @@ -218,97 +324,18 @@ class ChatConnection( } } - private inner class TwitchWebSocketListener : WebSocketListener() { - private var pingJob: Job? = null - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - _connected.value = 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.value = false - connecting = false - pingJob?.cancel() - channelsAttemptedToJoin.clear() - scope.launch { receiveChannel.send(ChatEvent.Closed) } - - attemptReconnect() - } - - override fun onOpen(webSocket: WebSocket, response: Response) { - _connected.value = 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 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..b9064d96c 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,7 +1,6 @@ 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 @@ -15,13 +14,22 @@ 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.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,56 +37,40 @@ 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 +@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 @@ -93,19 +85,86 @@ class PubSubConnection( } 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) + Log.i(TAG, "[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) + Log.i(TAG, "[PubSub $tag] connected") + + possibleTopics + .toRequestMessages() + .forEach { send(Frame.Text(it)) } + Log.d(TAG, "[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() + if (cause == null) 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) { + Log.i(TAG, "[PubSub $tag] connection closed") + return@launch + } + Log.i(TAG, "[PubSub $tag] reconnecting after server request") + + } catch (t: Throwable) { + if (t is CancellationException) throw t + + Log.e(TAG, "[PubSub $tag] connection failed: $t") + Log.e(TAG, "[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) + } + } + + Log.e(TAG, "[PubSub $tag] connection failed after $RECONNECT_MAX_ATTEMPTS retries") + session = null + } + return remainingTopics.toSet() } @@ -118,9 +177,15 @@ class PubSubConnection( val needsListen = (possibleTopics - topics) topics.addAll(needsListen) + if (needsListen.isNotEmpty()) { + Log.d(TAG, "[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() } @@ -133,18 +198,26 @@ class PubSubConnection( } 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() + Log.i(TAG, "[PubSub $tag] reconnecting") + close() + connect(topics) } fun reconnectIfNecessary() { - if (connected || connecting) return + if (connected || connectionJob?.isActive == true) return + Log.i(TAG, "[PubSub $tag] connection lost, reconnecting") reconnect() } @@ -152,26 +225,22 @@ class PubSubConnection( val foundTopics = topics.filter { it in toUnlisten }.toSet() topics.removeAll(foundTopics) + if (foundTopics.isNotEmpty()) { + Log.d(TAG, "[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,134 +248,112 @@ 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. + */ + private fun handleMessage(text: String): Boolean { + val json = JSONObject(text) + val type = json.optString("type").ifBlank { return false } + when (type) { + "PONG" -> awaitingPong = false + "RECONNECT" -> { + Log.i(TAG, "[PubSub $tag] server requested reconnect") + return true + } - 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 - } + "RESPONSE" -> { + val error = json.optString("error") + if (error.isNotBlank()) { + Log.w(TAG, "[PubSub $tag] RESPONSE error: $error") + } + } - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return - PubSubMessage.Whisper(parsedMessage.data) + "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.Whispers -> { + if (messageTopic !in listOf("whisper_sent", "whisper_received")) { + return false } - is PubSubTopic.PointRedemptions -> { - if (messageTopic != "reward-redeemed") { - return - } + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false + PubSubMessage.Whisper(parsedMessage.data) + } - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return - PubSubMessage.PointRedemption( - timestamp = parsedMessage.data.timestamp, - channelName = match.channelName, - channelId = match.channelId, - data = parsedMessage.data.redemption - ) + is PubSubTopic.PointRedemptions -> { + if (messageTopic != "reward-redeemed") { + return false } - is PubSubTopic.ModeratorActions -> { - when (messageTopic) { - "moderator_added" -> { - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return - 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 - ) - ) - } + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false + PubSubMessage.PointRedemption( + timestamp = parsedMessage.data.timestamp, + channelName = match.channelName, + channelId = match.channelId, + data = parsedMessage.data.redemption + ) + } - "moderation_action" -> { - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return - if (parsedMessage.data.moderationAction == ModerationActionType.Mod) { - return - } - 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 }, - ) + is PubSubTopic.ModeratorActions -> { + when (messageTopic) { + "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 ) - } + ) + } - else -> return + "moderation_action" -> { + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false + if (parsedMessage.data.moderationAction == ModerationActionType.Mod) { + 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 }, + ) + ) } + + else -> return false } } - receiveChannel.trySend(PubSubEvent.Message(pubSubMessage)) } + receiveChannel.trySend(PubSubEvent.Message(pubSubMessage)) } - } + return false } private fun Collection.splitAt(n: Int): Pair, Collection> { @@ -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 const val PUBSUB_URL = "wss://pubsub-edge.twitch.tv" private val TAG = PubSubConnection::class.java.simpleName } } 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 196483620..63b46cc60 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,15 +1,18 @@ package com.flxrs.dankchat.data.twitch.pubsub +import android.util.Log 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.toUserId 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.di.DispatchersProvider -import com.flxrs.dankchat.di.WebSocketOkHttpClient import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.WebSockets import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -23,8 +26,6 @@ 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 @Single @@ -33,11 +34,15 @@ class PubSubManager( private val chatChannelProvider: ChatChannelProvider, private val developerSettingsDataStore: DeveloperSettingsDataStore, private val authDataStore: AuthDataStore, - @Named(type = WebSocketOkHttpClient::class) private val client: OkHttpClient, + 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 = CoroutineChannel(capacity = CoroutineChannel.BUFFERED) @@ -51,6 +56,7 @@ class PubSubManager( init { scope.launch { + startupValidationHolder.awaitResolved() combine( authDataStore.settings.map { it.isLoggedIn to it.userId }.distinctUntilChanged(), chatChannelProvider.channels.filterNotNull(), @@ -59,9 +65,13 @@ class PubSubManager( Triple(if (isLoggedIn) userId else null, channels, shouldUsePubSub) }.collect { (userId, channels, shouldUsePubSub) -> closeAll() - if (userId == null) return@collect + if (userId == null) { + Log.d(TAG, "[PubSub] skipping connection, not logged in") + return@collect + } val resolved = channelRepository.getChannels(channels) val topics = buildTopics(userId, resolved, shouldUsePubSub) + Log.i(TAG, "[PubSub] rebuilding connections for ${resolved.size} channels, ${topics.size} topics (pubsub=$shouldUsePubSub)") listen(topics) } } @@ -155,4 +165,8 @@ class PubSubManager( } } } + + companion object { + private val TAG = PubSubManager::class.java.simpleName + } } 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 a80310810..6191f2a42 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt @@ -3,7 +3,7 @@ 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 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 @@ -17,16 +17,16 @@ class ConnectionModule { @Single @Named(type = ReadConnection::class) fun provideReadConnection( - @Named(type = WebSocketOkHttpClient::class) client: OkHttpClient, + httpClient: HttpClient, dispatchersProvider: DispatchersProvider, authDataStore: AuthDataStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Read, client, authDataStore, dispatchersProvider) + ): ChatConnection = ChatConnection(ChatConnectionType.Read, httpClient, authDataStore, dispatchersProvider) @Single @Named(type = WriteConnection::class) fun provideWriteConnection( - @Named(type = WebSocketOkHttpClient::class) client: OkHttpClient, + httpClient: HttpClient, dispatchersProvider: DispatchersProvider, authDataStore: AuthDataStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Write, client, authDataStore, dispatchersProvider) + ): ChatConnection = ChatConnection(ChatConnectionType.Write, httpClient, authDataStore, dispatchersProvider) } 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 index f74cd2af5..72ab86dac 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt @@ -13,15 +13,19 @@ fun List.addSystemMessage(type: SystemMessageType, scrollBackLength: I } private 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)) + // 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)) } + } - is SystemMessageType.ChannelNonExistent -> dropLast(1) + SystemMessageType.Connected.toChatItem() - else -> addAndLimit(SystemMessageType.Connected.toChatItem(), scrollBackLength, onMessageRemoved) + 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/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..9e6284c25 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/MockIrcServer.kt @@ -0,0 +1,89 @@ +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 { + return 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..cd7dcf722 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/TwitchIrcIntegrationTest.kt @@ -0,0 +1,225 @@ +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/ui/tour/FeatureTourViewModelTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt index 0d10b8cda..e0a1c492f 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt @@ -1,6 +1,8 @@ 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 @@ -45,7 +47,8 @@ internal class FeatureTourViewModelTest { settingsFlow.value = transform(settingsFlow.value) } - viewModel = FeatureTourViewModel(onboardingDataStore) + val startupValidationHolder = StartupValidationHolder().apply { update(StartupValidation.Validated) } + viewModel = FeatureTourViewModel(onboardingDataStore, startupValidationHolder) } @AfterEach diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fd4ece3de..232fa7897 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -124,6 +124,7 @@ 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" } autolinktext = { module = "sh.calvin.autolinktext:autolinktext", version.ref = "autoLinkText" } From 103d21100d1301ada5cc1a2cb6ae0acaed110504 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 11:46:41 +0200 Subject: [PATCH 150/349] refactor(ui): Migrate AlertDialogs to bottom sheets, add ConfirmationBottomSheet component --- .../developer/DeveloperSettingsScreen.kt | 24 +- .../preferences/tools/ToolsSettingsScreen.kt | 27 +- .../tools/upload/ImageUploaderScreen.kt | 39 +- .../dankchat/ui/chat/user/UserPopupDialog.kt | 249 +++++++------ .../ui/main/dialog/ConfirmationDialog.kt | 51 +-- .../ui/main/dialog/MainScreenDialogs.kt | 156 +++++--- .../ui/main/dialog/ManageChannelsDialog.kt | 2 +- .../ui/main/dialog/MessageOptionsDialog.kt | 344 ++++++++++-------- .../ui/main/dialog/ModActionsDialog.kt | 114 ++++-- .../flxrs/dankchat/utils/ErrorDialogUtil.kt | 25 -- .../utils/compose/ConfirmationBottomSheet.kt | 68 ++++ .../utils/compose/InputBottomSheet.kt | 20 +- .../utils/compose/StyledBottomSheet.kt | 33 +- 13 files changed, 663 insertions(+), 489 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/ErrorDialogUtil.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt 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 index e78e12c09..4554e126e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -27,7 +27,7 @@ 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 com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -461,21 +461,11 @@ private fun ShowScopesBottomSheet(scopes: String, onDismissRequested: () -> Unit @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)) } - ) - }, + 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 = onContinueRequested, + onDismiss = onDismissRequested, ) } 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..f1f061575 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,7 @@ 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 com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -187,25 +187,14 @@ fun ImageUploaderCategory( } if (confirmClearDialog) { - AlertDialog( - onDismissRequest = { confirmClearDialog = false }, - confirmButton = { - TextButton( - onClick = { - viewModel.clearUploads() - recentUploadSheetOpen = false - }, - content = { Text(stringResource(R.string.clear)) }, - ) - }, - dismissButton = { - TextButton( - onClick = { confirmClearDialog = false }, - content = { Text(stringResource(R.string.dialog_cancel)) }, - ) + ConfirmationBottomSheet( + title = stringResource(R.string.clear_recent_uploads_dialog_message), + confirmText = stringResource(R.string.clear), + onConfirm = { + viewModel.clearUploads() + recentUploadSheetOpen = false }, - title = { Text(stringResource(R.string.clear_recent_uploads_dialog_title)) }, - text = { Text(stringResource(R.string.clear_recent_uploads_dialog_message)) }, + onDismiss = { confirmClearDialog = false }, ) } } 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..86540759c 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,7 +20,7 @@ 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 com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -218,31 +218,20 @@ 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), + 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/ui/chat/user/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupDialog.kt index d88d42545..00630ab01 100644 --- 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 @@ -1,9 +1,16 @@ 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 @@ -19,8 +26,9 @@ 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.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -75,119 +83,148 @@ fun UserPopupDialog( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { - 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) - ) + AnimatedContent( + targetState = showBlockConfirmation, + transitionSpec = { + when { + targetState -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() } + }, + 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), + ) - else -> { - val userName = state.userName - val displayName = state.displayName - val isSuccess = state is UserPopupState.Success - val isBlocked = (state as? UserPopupState.Success)?.isBlocked == true + 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)) { + Text(stringResource(R.string.confirm_user_block_positive_button)) + } + } + } + } - UserInfoSection( - state = state, - userName = userName, - displayName = displayName, - badges = badges, - onOpenChannel = onOpenChannel, - ) + 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) + ) + } - 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 (!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) - ) - } - if (onMessageHistory != null) { - ListItem( - headlineContent = { Text(stringResource(R.string.message_history)) }, - leadingContent = { Icon(Icons.Default.History, contentDescription = null) }, - modifier = Modifier.clickable { - onMessageHistory(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 + 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, + ) + + 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 (!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) + ) } - }, - 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) - ) + if (onMessageHistory != null) { + ListItem( + headlineContent = { Text(stringResource(R.string.message_history)) }, + leadingContent = { Icon(Icons.Default.History, contentDescription = null) }, + modifier = Modifier.clickable { + onMessageHistory(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) + ) + } + } + } } } } } } - - if (showBlockConfirmation) { - AlertDialog( - onDismissRequest = { showBlockConfirmation = false }, - title = { Text(stringResource(R.string.confirm_user_block_title)) }, - text = { Text(stringResource(R.string.confirm_user_block_message)) }, - confirmButton = { - TextButton( - onClick = { - onBlockUser() - showBlockConfirmation = false - } - ) { - Text(stringResource(R.string.confirm_user_block_positive_button)) - } - }, - dismissButton = { - TextButton(onClick = { showBlockConfirmation = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun UserInfoSection( state: UserPopupState, @@ -261,11 +298,7 @@ private fun UserInfoSection( if (title != null) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - PlainTooltip { - Text(title) - } - }, + tooltip = { PlainTooltip { Text(title) } }, state = rememberTooltipState(), ) { AsyncImage( @@ -286,7 +319,7 @@ private fun UserInfoSection( } } - else -> {} // Loading — name is shown, details load later + else -> {} } } } 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 index 437223459..09ce5c161 100644 --- 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 @@ -1,24 +1,10 @@ package com.flxrs.dankchat.ui.main.dialog -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.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -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.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.utils.compose.ConfirmationBottomSheet -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfirmationDialog( title: String, @@ -27,32 +13,11 @@ fun ConfirmationDialog( onDismiss: () -> Unit, dismissText: String = stringResource(R.string.dialog_cancel), ) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - tonalElevation = 6.dp, - ) { - Column(modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 8.dp)) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onDismiss) { - Text(dismissText) - } - TextButton(onClick = onConfirm) { - Text(confirmText) - } - } - } - } - } + ConfirmationBottomSheet( + title = title, + confirmText = confirmText, + dismissText = dismissText, + onConfirm = onConfirm, + onDismiss = onDismiss, + ) } 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 index 00dc2a086..af9e05554 100644 --- 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 @@ -1,22 +1,33 @@ package com.flxrs.dankchat.ui.main.dialog import android.content.ClipData -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.layout.Arrangement +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.ExperimentalMaterial3Api -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.Modifier import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard +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.style.TextDecoration +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.data.DisplayName @@ -24,7 +35,10 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.auth.StartupValidation import com.flxrs.dankchat.data.auth.StartupValidationHolder import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import com.flxrs.dankchat.utils.compose.InfoBottomSheet +import com.flxrs.dankchat.utils.compose.InputBottomSheet +import com.flxrs.dankchat.utils.compose.StyledBottomSheet import com.flxrs.dankchat.ui.chat.emote.EmoteInfoViewModel import com.flxrs.dankchat.ui.chat.message.MessageOptionsState import com.flxrs.dankchat.ui.chat.message.MessageOptionsViewModel @@ -111,7 +125,7 @@ fun MainScreenDialogs( if (dialogState.showRemoveChannel && activeChannel != null) { ConfirmationDialog( - title = stringResource(R.string.confirm_channel_removal_question_named, activeChannel), + title = stringResource(R.string.confirm_channel_removal_message_named, activeChannel), confirmText = stringResource(R.string.confirm_channel_removal_positive_button), onConfirm = { channelManagementViewModel.removeChannel(activeChannel) @@ -123,7 +137,7 @@ fun MainScreenDialogs( if (dialogState.showBlockChannel && activeChannel != null) { ConfirmationDialog( - title = stringResource(R.string.confirm_channel_block_question_named, activeChannel), + title = stringResource(R.string.confirm_channel_block_message_named, activeChannel), confirmText = stringResource(R.string.confirm_user_block_positive_button), onConfirm = { channelManagementViewModel.blockChannel(activeChannel) @@ -134,8 +148,8 @@ fun MainScreenDialogs( } if (dialogState.showLogout) { - ConfirmationDialog( - title = stringResource(R.string.confirm_logout_question), + ConfirmationBottomSheet( + title = stringResource(R.string.confirm_logout_message), confirmText = stringResource(R.string.confirm_logout_positive_button), onConfirm = { onLogout() @@ -146,61 +160,29 @@ fun MainScreenDialogs( } if (dialogState.pendingUploadAction != null) { - AlertDialog( - onDismissRequest = { dialogViewModel.setPendingUploadAction(null) }, - title = { Text(stringResource(R.string.nuuls_upload_title)) }, - text = { Text(stringResource(R.string.external_upload_disclaimer, dialogViewModel.uploadHost)) }, - confirmButton = { - TextButton( - onClick = { - dialogViewModel.acknowledgeExternalHosting() - val action = dialogState.pendingUploadAction - dialogViewModel.setPendingUploadAction(null) - action?.invoke() - } - ) { - Text(stringResource(R.string.dialog_ok)) - } + UploadDisclaimerSheet( + host = dialogViewModel.uploadHost, + onConfirm = { + dialogViewModel.acknowledgeExternalHosting() + val action = dialogState.pendingUploadAction + dialogViewModel.setPendingUploadAction(null) + action?.invoke() }, - dismissButton = { - TextButton(onClick = { dialogViewModel.setPendingUploadAction(null) }) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = { dialogViewModel.setPendingUploadAction(null) }, ) } if (dialogState.showNewWhisper) { - var whisperUsername by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = dialogViewModel::dismissNewWhisper, - title = { Text(stringResource(R.string.whisper_new_dialog_title)) }, - text = { - OutlinedTextField( - value = whisperUsername, - onValueChange = { whisperUsername = it }, - label = { Text(stringResource(R.string.whisper_new_dialog_hint)) }, - singleLine = true, - ) + 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() }, - confirmButton = { - TextButton( - onClick = { - val username = whisperUsername.trim() - if (username.isNotBlank()) { - chatInputViewModel.setWhisperTarget(UserName(username)) - dialogViewModel.dismissNewWhisper() - } - } - ) { - Text(stringResource(R.string.whisper_new_dialog_start)) - } - }, - dismissButton = { - TextButton(onClick = dialogViewModel::dismissNewWhisper) { - Text(stringResource(R.string.dialog_cancel)) - } - } + onDismiss = dialogViewModel::dismissNewWhisper, ) } @@ -374,3 +356,61 @@ fun MainScreenDialogs( ) } } + +@Composable +private fun UploadDisclaimerSheet( + host: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val uriHandler = 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, + ) + } + } + + StyledBottomSheet(onDismiss = onDismiss) { + Text( + text = stringResource(R.string.nuuls_upload_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.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)) + } + } + } +} 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 index 5728cd089..1d40cc3e2 100644 --- 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 @@ -170,7 +170,7 @@ fun ManageChannelsDialog( if (channelToDelete != null) { ConfirmationDialog( - title = stringResource(R.string.confirm_channel_removal_question), + title = stringResource(R.string.confirm_channel_removal_message), confirmText = stringResource(R.string.confirm_channel_removal_positive_button), onConfirm = { val channel = channelToDelete 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 index b85979b12..12eb514dc 100644 --- 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 @@ -1,11 +1,20 @@ 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.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.height 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 @@ -14,8 +23,9 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Timer -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -35,11 +45,19 @@ 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.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( @@ -63,156 +81,125 @@ fun MessageOptionsDialog( onUnban: () -> Unit, onDismiss: () -> Unit, ) { - var showTimeoutDialog by remember { mutableStateOf(false) } - var showBanDialog by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(false) } + var subView by remember { mutableStateOf(null) } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - if (canReply) { - MessageOptionItem( - icon = Icons.AutoMirrored.Filled.Reply, - text = stringResource(R.string.message_reply), - onClick = { - onReply() - onDismiss() - } - ) - } - if (canReply && hasReplyThread) { - MessageOptionItem( - icon = Icons.AutoMirrored.Filled.Reply, - text = stringResource(R.string.message_reply_original), - onClick = { - onReplyToOriginal() - onDismiss() - } - ) - MessageOptionItem( - icon = Icons.AutoMirrored.Filled.Reply, - text = stringResource(R.string.message_view_thread), - onClick = { - onViewThread() - onDismiss() - } - ) - } - - if (canJump && channel != null) { - MessageOptionItem( - icon = Icons.AutoMirrored.Filled.OpenInNew, - text = stringResource(R.string.message_jump_to), - onClick = { - onJumpToMessage() - onDismiss() - } + AnimatedContent( + targetState = subView, + transitionSpec = { + when { + targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + } + }, + 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() }, + onMoreActions = { onMoreActions(); onDismiss() }, + onUnban = { onUnban(); onDismiss() }, + onTimeout = { subView = MessageOptionsSubView.Timeout }, + onBan = { subView = MessageOptionsSubView.Ban }, + onDelete = { subView = MessageOptionsSubView.Delete }, ) - } - if (canCopy) { - MessageOptionItem( - icon = Icons.Default.ContentCopy, - text = stringResource(R.string.message_copy), - onClick = { - onCopy() + MessageOptionsSubView.Timeout -> TimeoutSubView( + onConfirm = { index -> + onTimeout(index) onDismiss() - } + }, + onBack = { subView = null }, ) - MessageOptionItem( - icon = Icons.Default.MoreVert, - text = stringResource(R.string.message_more_actions), - onClick = { - onMoreActions() - // Don't call onDismiss() here if the state management in MainScreen - // handles switching sheets, but the user said "it closes the current sheet" - // If we are using ModalBottomSheet, opening another one usually dismisses the first. + MessageOptionsSubView.Ban -> ConfirmationSubView( + title = stringResource(R.string.confirm_user_ban_message), + confirmText = stringResource(R.string.confirm_user_ban_positive_button), + onConfirm = { + onBan() onDismiss() - } - ) - } - - if (canModerate) { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - - MessageOptionItem( - icon = Icons.Default.Timer, - text = stringResource(R.string.user_popup_timeout), - onClick = { showTimeoutDialog = true } - ) - - MessageOptionItem( - icon = Icons.Default.Delete, - text = stringResource(R.string.user_popup_delete), - onClick = { showDeleteDialog = true } + }, + onBack = { subView = null }, ) - MessageOptionItem( - icon = Icons.Default.Gavel, - text = stringResource(R.string.user_popup_ban), - onClick = { showBanDialog = true } - ) - - MessageOptionItem( - icon = Icons.Default.Gavel, // Using same icon for unban - text = stringResource(R.string.user_popup_unban), - onClick = { - onUnban() + 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 }, ) } } } +} - if (showTimeoutDialog) { - TimeoutConfirmDialog( - onConfirm = { index -> - onTimeout(index) - showTimeoutDialog = false - onDismiss() - }, - onDismiss = { showTimeoutDialog = false } - ) - } - - if (showBanDialog) { - ConfirmationDialog( - title = stringResource(R.string.confirm_user_ban_question), - confirmText = stringResource(R.string.confirm_user_ban_positive_button), - onConfirm = { - onBan() - showBanDialog = false - onDismiss() - }, - onDismiss = { showBanDialog = false }, - ) - } - - if (showDeleteDialog) { - ConfirmationDialog( - title = stringResource(R.string.confirm_user_delete_question), - confirmText = stringResource(R.string.confirm_user_delete_positive_button), - onConfirm = { - onDelete() - showDeleteDialog = false - onDismiss() - }, - onDismiss = { showDeleteDialog = false }, - ) +@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, + onMoreActions: () -> Unit, + onUnban: () -> Unit, + onTimeout: () -> Unit, + onBan: () -> Unit, + onDelete: () -> Unit, +) { + 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) + MessageOptionItem(Icons.Default.MoreVert, stringResource(R.string.message_more_actions), onMoreActions) + } + 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: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, text: String, onClick: () -> Unit ) { @@ -225,41 +212,94 @@ private fun MessageOptionItem( } @Composable -private fun TimeoutConfirmDialog( +private fun TimeoutSubView( onConfirm: (Int) -> Unit, - onDismiss: () -> Unit + onBack: () -> Unit, ) { val choices = stringArrayResource(R.array.timeout_entries) var sliderPosition by remember { mutableFloatStateOf(0f) } val currentIndex = sliderPosition.toInt() - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.confirm_user_timeout_title)) }, - text = { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = choices[currentIndex], - style = MaterialTheme.typography.headlineMedium - ) - Spacer(modifier = Modifier.height(16.dp)) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - valueRange = 0f..(choices.size - 1).toFloat(), - steps = choices.size - 2 - ) + 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)) } - }, - confirmButton = { - TextButton(onClick = { onConfirm(currentIndex) }) { + Spacer(modifier = Modifier.width(12.dp)) + Button(onClick = { onConfirm(currentIndex) }, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.confirm_user_timeout_positive_button)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { + } + } +} + +@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)) { + 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 index 281f06f5f..8200390bf 100644 --- 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 @@ -8,17 +8,25 @@ 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.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +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.ui.Alignment 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.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.AssistChip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -34,14 +42,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalDensity 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.text.TextRange import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R import com.flxrs.dankchat.data.twitch.message.RoomState @@ -55,6 +67,7 @@ private sealed interface SubView { data object CommercialPresets : SubView data object RaidInput : SubView data object ShoutoutInput : SubView + data object ClearChatConfirm : SubView } private val SLOW_MODE_PRESETS = listOf(3, 5, 10, 20, 30, 60, 120) @@ -95,29 +108,6 @@ fun ModActionsDialog( onDismiss: () -> Unit, ) { var subView by remember { mutableStateOf(null) } - var showClearChatConfirmation by remember { mutableStateOf(false) } - - if (showClearChatConfirmation) { - AlertDialog( - onDismissRequest = { showClearChatConfirmation = false }, - title = { Text(stringResource(R.string.mod_actions_clear_chat)) }, - text = { Text(stringResource(R.string.mod_actions_confirm_clear_chat)) }, - confirmButton = { - TextButton(onClick = { - onSendCommand("/clear") - showClearChatConfirmation = false - onDismiss() - }) { - Text(stringResource(R.string.dialog_ok)) - } - }, - dismissButton = { - TextButton(onClick = { showClearChatConfirmation = false }) { - Text(stringResource(R.string.dialog_cancel)) - } - } - ) - } run { ModalBottomSheet( @@ -142,7 +132,7 @@ fun ModActionsDialog( shieldModeActive = shieldModeActive, onSendCommand = onSendCommand, onShowSubView = { subView = it }, - onClearChat = { showClearChatConfirmation = true }, + onClearChat = { subView = SubView.ClearChatConfirm }, onAnnounce = onAnnounce, onDismiss = onDismiss, ) @@ -167,6 +157,7 @@ fun ModActionsDialog( onSendCommand("/slow $value") onDismiss() }, + onDismiss = onDismiss, ) SubView.FollowerMode -> FollowerPresetChips( @@ -186,6 +177,7 @@ fun ModActionsDialog( onSendCommand("/followers $value") onDismiss() }, + onDismiss = onDismiss, ) SubView.CommercialPresets -> PresetChips( @@ -206,6 +198,7 @@ fun ModActionsDialog( onSendCommand("/raid $target") onDismiss() }, + onDismiss = onDismiss, ) SubView.ShoutoutInput -> UserInputSubView( @@ -215,6 +208,15 @@ fun ModActionsDialog( onSendCommand("/shoutout $target") onDismiss() }, + onDismiss = onDismiss, + ) + + SubView.ClearChatConfirm -> ClearChatConfirmSubView( + onConfirm = { + onSendCommand("/clear") + onDismiss() + }, + onBack = { subView = null }, ) } } @@ -537,14 +539,28 @@ private fun UserInputSubView( defaultValue: String = "", keyboardType: KeyboardType = KeyboardType.Text, onConfirm: (String) -> Unit, + onDismiss: () -> Unit = {}, ) { - var inputValue by remember { mutableStateOf(defaultValue) } + 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() @@ -566,8 +582,9 @@ private fun UserInputSubView( singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { - if (inputValue.isNotBlank()) { - onConfirm(inputValue.trim()) + val text = inputValue.text.trim() + if (text.isNotBlank()) { + onConfirm(text) } }), modifier = Modifier @@ -576,8 +593,8 @@ private fun UserInputSubView( ) TextButton( - onClick = { onConfirm(inputValue.trim()) }, - enabled = inputValue.isNotBlank(), + onClick = { onConfirm(inputValue.text.trim()) }, + enabled = inputValue.text.isNotBlank(), modifier = Modifier .align(Alignment.End) .padding(top = 8.dp), @@ -586,3 +603,40 @@ private fun UserInputSubView( } } } + +@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)) { + Text(stringResource(R.string.dialog_ok)) + } + } + } +} 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/compose/ConfirmationBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt new file mode 100644 index 000000000..ff3803e62 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt @@ -0,0 +1,68 @@ +package com.flxrs.dankchat.utils.compose + +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.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +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 + +@Composable +fun ConfirmationBottomSheet( + title: String, + message: String? = null, + confirmText: String = stringResource(R.string.dialog_ok), + dismissText: String = stringResource(R.string.dialog_cancel), + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + StyledBottomSheet(onDismiss = onDismiss) { + 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), + ) { + 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 index 86965b2d5..343401844 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt @@ -13,10 +13,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField 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 @@ -28,8 +28,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester 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.flxrs.dankchat.R @@ -45,9 +47,9 @@ fun InputBottomSheet( onConfirm: (String) -> Unit, onDismiss: () -> Unit, ) { - var inputValue by remember { mutableStateOf(defaultValue) } + var inputValue by remember { mutableStateOf(TextFieldValue(defaultValue, selection = TextRange(defaultValue.length))) } val focusRequester = remember { FocusRequester() } - val trimmed = inputValue.trim() + val trimmed = inputValue.text.trim() val errorText = validate?.invoke(trimmed) val isValid = trimmed.isNotBlank() && errorText == null @@ -55,11 +57,11 @@ fun InputBottomSheet( focusRequester.requestFocus() } - StyledBottomSheet(onDismiss = onDismiss) { + StyledBottomSheet(onDismiss = onDismiss, addBottomSpacing = false, dismissOnKeyboardClose = true) { Text( text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 12.dp), ) @@ -69,9 +71,9 @@ fun InputBottomSheet( label = { Text(hint) }, singleLine = true, isError = errorText != null, - trailingIcon = if (showClearButton && inputValue.isNotEmpty()) { + trailingIcon = if (showClearButton && inputValue.text.isNotEmpty()) { { - IconButton(onClick = { inputValue = "" }) { + IconButton(onClick = { inputValue = TextFieldValue() }) { Icon( imageVector = Icons.Default.Clear, contentDescription = stringResource(R.string.clear), @@ -108,7 +110,7 @@ fun InputBottomSheet( ) } - TextButton( + Button( onClick = { onConfirm(trimmed) }, enabled = isValid, modifier = Modifier diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt index 093f5e458..45bc09fbd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt @@ -4,7 +4,11 @@ import androidx.activity.compose.PredictiveBackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +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 @@ -15,13 +19,17 @@ 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.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.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.composables.core.DragIndication import com.composables.core.ModalBottomSheet @@ -29,11 +37,14 @@ import com.composables.core.Scrim import com.composables.core.Sheet import com.composables.core.SheetDetent import com.composables.core.rememberModalBottomSheetState +import kotlinx.coroutines.flow.distinctUntilChanged import java.util.concurrent.CancellationException @Composable fun StyledBottomSheet( onDismiss: () -> Unit, + addBottomSpacing: Boolean = true, + dismissOnKeyboardClose: Boolean = false, content: @Composable ColumnScope.() -> Unit, ) { val sheetState = rememberModalBottomSheetState( @@ -65,13 +76,15 @@ fun StyledBottomSheet( } } - val scale = 1f - (backProgress * 0.1f) + 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)) @@ -81,12 +94,28 @@ fun StyledBottomSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) + .padding(bottom = if (addBottomSpacing) 32.dp else 0.dp) .navigationBarsPadding() .imePadding(), ) { + if (dismissOnKeyboardClose) { + 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(bottom = 12.dp) + .padding(top = 16.dp, bottom = 16.dp) .align(Alignment.CenterHorizontally) .background( MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), From 5667fc16610a90c166ecb9fb4b72e171e96ad40c Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 12:12:59 +0200 Subject: [PATCH 151/349] feat(mod): Add Shield Mode activation confirmation, unify centered title style for title+body sheets --- .../ui/main/dialog/MainScreenDialogs.kt | 6 +- .../ui/main/dialog/ModActionsDialog.kt | 62 ++++++++++++++++++- .../main/res/values-b+zh+Hant+TW/strings.xml | 5 +- app/src/main/res/values-be-rBY/strings.xml | 5 +- app/src/main/res/values-ca/strings.xml | 5 +- app/src/main/res/values-cs/strings.xml | 5 +- app/src/main/res/values-de-rDE/strings.xml | 5 +- app/src/main/res/values-en-rAU/strings.xml | 5 +- app/src/main/res/values-en-rGB/strings.xml | 5 +- app/src/main/res/values-en/strings.xml | 5 +- app/src/main/res/values-es-rES/strings.xml | 5 +- app/src/main/res/values-fi-rFI/strings.xml | 5 +- app/src/main/res/values-fr-rFR/strings.xml | 5 +- app/src/main/res/values-hu-rHU/strings.xml | 5 +- app/src/main/res/values-it/strings.xml | 5 +- app/src/main/res/values-ja-rJP/strings.xml | 5 +- app/src/main/res/values-kk-rKZ/strings.xml | 5 +- app/src/main/res/values-or-rIN/strings.xml | 5 +- app/src/main/res/values-pl-rPL/strings.xml | 5 +- app/src/main/res/values-pt-rBR/strings.xml | 5 +- app/src/main/res/values-pt-rPT/strings.xml | 5 +- app/src/main/res/values-ru-rRU/strings.xml | 5 +- app/src/main/res/values-sr/strings.xml | 5 +- app/src/main/res/values-tr-rTR/strings.xml | 5 +- app/src/main/res/values-uk-rUA/strings.xml | 5 +- app/src/main/res/values/strings.xml | 3 + 26 files changed, 160 insertions(+), 26 deletions(-) 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 index af9e05554..0703ce0b6 100644 --- 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 @@ -26,6 +26,7 @@ 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.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -390,7 +391,10 @@ private fun UploadDisclaimerSheet( text = stringResource(R.string.nuuls_upload_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), ) Text( 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 index 8200390bf..9d9a4c65c 100644 --- 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 @@ -68,6 +68,7 @@ private sealed interface 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) @@ -211,6 +212,14 @@ fun ModActionsDialog( onDismiss = onDismiss, ) + SubView.ShieldModeConfirm -> ShieldModeConfirmSubView( + onConfirm = { + onSendCommand("/shield") + onDismiss() + }, + onBack = { subView = null }, + ) + SubView.ClearChatConfirm -> ClearChatConfirmSubView( onConfirm = { onSendCommand("/clear") @@ -265,8 +274,14 @@ private fun ModActionsMainView( FilterChip( selected = isShieldActive, onClick = { - onSendCommand(if (isShieldActive) "/shieldoff" else "/shield") - onDismiss() + when { + isShieldActive -> { + onSendCommand("/shieldoff") + onDismiss() + } + + else -> onShowSubView(SubView.ShieldModeConfirm) + } }, label = { Text(stringResource(R.string.mod_actions_shield_mode)) }, leadingIcon = if (isShieldActive) { @@ -640,3 +655,46 @@ private fun ClearChatConfirmSubView( } } } + +@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/res/values-b+zh+Hant+TW/strings.xml b/app/src/main/res/values-b+zh+Hant+TW/strings.xml index 6f3756cba..1a914f110 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 @@ -291,7 +291,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + 啟用護盾模式? + 這將套用頻道預先設定的安全設定,可能包括聊天限制、AutoMod 設定和聊天驗證要求。 + 啟用 Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index f40b1d0af..bc1af2c1e 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -603,7 +603,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + Актываваць рэжым шчыта? + Гэта прыменіць папярэдне наладжаныя параметры бяспекі канала, якія могуць уключаць абмежаванні чата, налады AutoMod і патрабаванні верыфікацыі. + Актываваць Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 26fb5b3c9..de0ec9c2b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -538,7 +538,10 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index aa1ad62b6..680dcea6a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -604,7 +604,10 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 9435bc319..fa53daac3 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -604,7 +604,10 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Shield mode Clear chat Clear all messages in this channel? - Announce + + Schildmodus aktivieren? + Dies wendet die vorkonfigurierten Sicherheitseinstellungen des Kanals an, darunter Chat-Einschränkungen, AutoMod-Überschreibungen und Chat-Verifizierungsanforderungen. + Aktivieren Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 5515e2d5f..8a60b9a54 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -416,7 +416,10 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 25e6f952b..e65c648e5 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -417,7 +417,10 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 3f7b7853f..121ed29af 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -598,7 +598,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 0b68f7bcd..afaf3dc87 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -613,7 +613,10 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Shield mode Clear chat Clear all messages in this channel? - Announce + + ¿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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 5a81bcc6c..1e50eb157 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -595,7 +595,10 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 39f65057b..08d49fd09 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -597,7 +597,10 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 618168bd0..0ade61ab4 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -582,7 +582,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e4a2f8ce3..8c6653d02 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -580,7 +580,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 2984949b6..b08c071bc 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -563,7 +563,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + シールドモードを有効にしますか? + チャンネルの事前設定された安全設定が適用されます。チャット制限、AutoMod設定、チャット認証要件が含まれる場合があります。 + 有効にする Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 8349d2401..30b4c2e21 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -290,7 +290,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + Қалқан режимін іске қосу керек пе? + Бұл арнаның алдын ала конфигурацияланған қауіпсіздік параметрлерін қолданады, оның ішінде чат шектеулері, AutoMod параметрлері және тексеру талаптары болуы мүмкін. + Іске қосу Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 94c600c9a..21f8e4c3e 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -290,7 +290,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + ଶିଲ୍ଡ ମୋଡ୍ ସକ୍ରିୟ କରିବେ? + ଏହା ଚ୍ୟାନେଲର ପୂର୍ବ-କନଫିଗର୍ ହୋଇଥିବା ସୁରକ୍ଷା ସେଟିଂ ପ୍ରୟୋଗ କରିବ, ଯାହା ଚାଟ୍ ସୀମାବଦ୍ଧତା, AutoMod ସେଟିଂ ଏବଂ ଯାଞ୍ଚ ଆବଶ୍ୟକତା ଅନ୍ତର୍ଭୁକ୍ତ କରିପାରେ। + ସକ୍ରିୟ କରନ୍ତୁ Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index c04c2201c..558d80402 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -622,7 +622,10 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4b1c1ee20..3b27d46b2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -592,7 +592,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index f05645f55..37705a24f 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -582,7 +582,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index e6eca65aa..f5cfb9920 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -608,7 +608,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + Активировать режим щита? + Это применит предварительно настроенные параметры безопасности канала, которые могут включать ограничения чата, настройки AutoMod и требования верификации. + Активировать Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index d6b88b924..7a054a12a 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -603,7 +603,10 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Shield mode Clear chat Clear all messages in this channel? - Announce + + Активирати режим штита? + Ово ће применити унапред конфигурисане безбедносне поставке канала, које могу укључивати ограничења ћаскања, подешавања AutoMod-а и захтеве за верификацију. + Активирај Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index ddb5844ba..0b1ed1aaf 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -603,7 +603,10 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Shield mode Clear chat Clear all messages in this channel? - Announce + + 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 8e83236c7..5774aee6e 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -605,7 +605,10 @@ Shield mode Clear chat Clear all messages in this channel? - Announce + + Активувати режим щита? + Це застосує попередньо налаштовані параметри безпеки каналу, які можуть включати обмеження чату, налаштування AutoMod та вимоги верифікації. + Активувати Announce Announcement Shoutout Commercial diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d867e1a36..6c5dcf531 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -310,6 +310,9 @@ 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 From 4f5daa25fbe2e9efc4314be24c262bc855112216 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 14:02:38 +0200 Subject: [PATCH 152/349] fix(ui): Use surfaceContainerHigh for all bottom sheets, enlarge send button, inline more actions into message options --- .../chat/userdisplay/UserDisplayScreen.kt | 1 + .../components/PreferenceListDialog.kt | 1 + .../components/PreferenceMultiListDialog.kt | 1 + .../developer/DeveloperSettingsScreen.kt | 16 ++++- .../highlights/HighlightsScreen.kt | 1 + .../preferences/tools/ToolsSettingsScreen.kt | 1 + .../dankchat/ui/chat/user/UserPopupDialog.kt | 1 + .../ui/main/dialog/EmoteInfoDialog.kt | 1 + .../ui/main/dialog/MainScreenDialogs.kt | 35 +++------- .../ui/main/dialog/ManageChannelsDialog.kt | 1 + .../ui/main/dialog/MessageOptionsDialog.kt | 47 +++++++++++-- .../ui/main/dialog/ModActionsDialog.kt | 3 +- .../dankchat/ui/main/input/ChatInputLayout.kt | 70 +++++++++---------- .../dankchat/ui/main/sheet/DebugInfoSheet.kt | 1 + .../dankchat/ui/main/sheet/EmoteMenuSheet.kt | 3 +- .../ui/main/sheet/MoreActionsSheet.kt | 62 ---------------- .../ui/main/sheet/SheetNavigationViewModel.kt | 7 -- .../dankchat/utils/compose/InfoBottomSheet.kt | 3 +- .../utils/compose/StyledBottomSheet.kt | 2 +- 19 files changed, 116 insertions(+), 141 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MoreActionsSheet.kt 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 72aa38de3..290d79b44 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 @@ -278,6 +278,7 @@ private fun UserDisplayItem( onChange(item.copy(color = selectedColor)) showColorPicker = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Text( text = stringResource(R.string.pick_custom_user_color_title), 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..ed50b72ce 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 @@ -46,6 +46,7 @@ fun PreferenceListDialog( ModalBottomSheet( onDismissRequest = ::dismiss, sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { values.forEachIndexed { idx, it -> val interactionSource = remember { MutableInteractionSource() } 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..780fca447 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 @@ -51,6 +51,7 @@ fun PreferenceMultiListDialog( onChanged(values.filterIndexed { idx, _ -> selected[idx] }) }, sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { entries.forEachIndexed { idx, it -> val interactionSource = remember { MutableInteractionSource() } 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 index 4554e126e..71f57f1ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -258,7 +258,10 @@ private fun CustomRecentMessagesHostBottomSheet( onInteraction: (DeveloperSettingsInteraction) -> Unit, ) { var host by remember(initialHost) { mutableStateOf(initialHost) } - ModalBottomSheet(onDismissRequest = { onInteraction(DeveloperSettingsInteraction.CustomRecentMessagesHost(host)) }) { + ModalBottomSheet( + onDismissRequest = { onInteraction(DeveloperSettingsInteraction.CustomRecentMessagesHost(host)) }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { Text( text = stringResource(R.string.preference_rm_host_title), textAlign = TextAlign.Center, @@ -318,7 +321,10 @@ private fun CustomLoginBottomSheet( } } - ModalBottomSheet(onDismissRequest = onDismissRequested) { + ModalBottomSheet( + onDismissRequest = onDismissRequested, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { Column(Modifier.padding(horizontal = 16.dp)) { Text( text = stringResource(R.string.preference_custom_login_title), @@ -429,7 +435,11 @@ private fun CustomLoginBottomSheet( private fun ShowScopesBottomSheet(scopes: String, onDismissRequested: () -> Unit) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() - ModalBottomSheet(onDismissRequested, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) { + ModalBottomSheet( + onDismissRequest = onDismissRequested, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { Column(Modifier.padding(horizontal = 16.dp)) { Text( text = stringResource(R.string.custom_login_required_scopes), 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 32771b7ca..7f026d011 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 @@ -685,6 +685,7 @@ private fun HighlightColorPicker( onColorSelected(selectedColor) showColorPicker = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Text( text = stringResource(R.string.pick_highlight_color_title), 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 f1f061575..6eae2e34a 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 @@ -167,6 +167,7 @@ fun ImageUploaderCategory( ModalBottomSheet( onDismissRequest = { recentUploadSheetOpen = false }, modifier = Modifier.statusBarsPadding(), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Text( text = stringResource(R.string.preference_uploader_recent_uploads_title), 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 index 00630ab01..b090ecf50 100644 --- 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 @@ -82,6 +82,7 @@ fun UserPopupDialog( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { AnimatedContent( targetState = showBlockConfirmation, 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 index 1ea8ec102..8303a961b 100644 --- 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 @@ -55,6 +55,7 @@ fun EmoteInfoDialog( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column(modifier = Modifier.fillMaxWidth()) { if (items.size > 1) { 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 index 0703ce0b6..0563264ac 100644 --- 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 @@ -51,7 +51,6 @@ import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState import com.flxrs.dankchat.ui.main.sheet.InputSheetState import com.flxrs.dankchat.ui.main.sheet.DebugInfoSheet import com.flxrs.dankchat.ui.main.sheet.DebugInfoViewModel -import com.flxrs.dankchat.ui.main.sheet.MoreActionsSheet import com.flxrs.dankchat.ui.main.sheet.SheetNavigationViewModel import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -249,8 +248,17 @@ fun MainScreenDialogs( snackbarHostState.showSnackbar(messageCopiedMsg) } }, - onMoreActions = { - sheetNavigationViewModel.openMoreActions(s.messageId, params.fullMessage) + onCopyFullMessage = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", params.fullMessage))) + snackbarHostState.showSnackbar(messageCopiedMsg) + } + }, + onCopyMessageId = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", s.messageId))) + snackbarHostState.showSnackbar(messageIdCopiedMsg) + } }, onDelete = viewModel::deleteMessage, onTimeout = viewModel::timeoutUser, @@ -327,27 +335,6 @@ fun MainScreenDialogs( ) } - if (inputSheetState is InputSheetState.MoreActions) { - MoreActionsSheet( - messageId = inputSheetState.messageId, - fullMessage = inputSheetState.fullMessage, - onCopyFullMessage = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", it))) - snackbarHostState.showSnackbar(messageCopiedMsg) - } - }, - onCopyMessageId = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", it))) - snackbarHostState.showSnackbar(messageIdCopiedMsg) - } - }, - onDismiss = sheetNavigationViewModel::closeInputSheet, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) - } - if (inputSheetState is InputSheetState.DebugInfo) { val debugInfoViewModel: DebugInfoViewModel = koinViewModel() DebugInfoSheet( 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 index 1d40cc3e2..23d3d6bec 100644 --- 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 @@ -99,6 +99,7 @@ fun ManageChannelsDialog( }, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), contentWindowInsets = { WindowInsets.statusBars }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { val navBarPadding = WindowInsets.navigationBars.asPaddingValues() LazyColumn( 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 index 12eb514dc..3f3318b45 100644 --- 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 @@ -6,6 +6,10 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,7 +25,7 @@ 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.MoreVert +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -45,6 +49,7 @@ 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.draw.rotate import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource @@ -74,7 +79,8 @@ fun MessageOptionsDialog( onJumpToMessage: () -> Unit, onViewThread: () -> Unit, onCopy: () -> Unit, - onMoreActions: () -> Unit, + onCopyFullMessage: () -> Unit, + onCopyMessageId: () -> Unit, onDelete: () -> Unit, onTimeout: (index: Int) -> Unit, onBan: () -> Unit, @@ -86,6 +92,7 @@ fun MessageOptionsDialog( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { AnimatedContent( targetState = subView, @@ -110,7 +117,8 @@ fun MessageOptionsDialog( onJumpToMessage = { onJumpToMessage(); onDismiss() }, onViewThread = { onViewThread(); onDismiss() }, onCopy = { onCopy(); onDismiss() }, - onMoreActions = { onMoreActions(); onDismiss() }, + onCopyFullMessage = { onCopyFullMessage(); onDismiss() }, + onCopyMessageId = { onCopyMessageId(); onDismiss() }, onUnban = { onUnban(); onDismiss() }, onTimeout = { subView = MessageOptionsSubView.Timeout }, onBan = { subView = MessageOptionsSubView.Ban }, @@ -162,12 +170,19 @@ private fun MessageOptionsMainView( onJumpToMessage: () -> Unit, onViewThread: () -> Unit, onCopy: () -> Unit, - onMoreActions: () -> 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() @@ -185,7 +200,29 @@ private fun MessageOptionsMainView( } if (canCopy) { MessageOptionItem(Icons.Default.ContentCopy, stringResource(R.string.message_copy), onCopy) - MessageOptionItem(Icons.Default.MoreVert, stringResource(R.string.message_more_actions), onMoreActions) + 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)) 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 index 9d9a4c65c..3781a9a5d 100644 --- 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 @@ -113,7 +113,8 @@ fun ModActionsDialog( run { ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { AnimatedContent( targetState = subView, 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 index d8a9ba920..03862f194 100644 --- 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 @@ -473,6 +473,7 @@ private fun InputActionConfigSheet( onDismiss() }, sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column( modifier = Modifier @@ -623,44 +624,39 @@ private fun SendButton( else -> MaterialTheme.colorScheme.primary } - when { - enabled && isRepeatedSendEnabled -> { - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .size(40.dp) - .pointerInput(Unit) { - detectTapGestures( - onTap = { onSend() }, - onLongPress = { onRepeatedSendChanged(true) }, - onPress = { - tryAwaitRelease() - onRepeatedSendChanged(false) - }, - ) - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.send_hint), - tint = contentColor - ) - } + val gestureModifier = when { + enabled && isRepeatedSendEnabled -> Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { onSend() }, + onLongPress = { onRepeatedSendChanged(true) }, + onPress = { + tryAwaitRelease() + onRepeatedSendChanged(false) + }, + ) } - else -> { - IconButton( - onClick = onSend, - enabled = enabled, - modifier = modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.send_hint), - tint = contentColor - ) - } - } + 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, + ) } } @@ -950,11 +946,13 @@ private fun EndAlignedActionGroup( } // Send Button (Right) + Spacer(modifier = Modifier.width(4.dp)) SendButton( enabled = canSend, isRepeatedSendEnabled = isRepeatedSendEnabled, onSend = onSend, onRepeatedSendChanged = onRepeatedSendChanged, + modifier = Modifier.size(44.dp), ) } 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 index c0a0d8b0c..354e96512 100644 --- 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 @@ -47,6 +47,7 @@ fun DebugInfoSheet( onDismissRequest = onDismiss, sheetState = sheetState, contentWindowInsets = { WindowInsets.statusBars }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { val navBarPadding = WindowInsets.navigationBars.asPaddingValues() LazyColumn( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt index 2e4fc543c..b380d9b51 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt @@ -54,7 +54,8 @@ fun EmoteMenuSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - modifier = Modifier.height(400.dp) // Fixed height for emote menu + modifier = Modifier.height(400.dp), // Fixed height for emote menu + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column(modifier = Modifier.fillMaxSize()) { PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MoreActionsSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MoreActionsSheet.kt deleted file mode 100644 index a479387f7..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MoreActionsSheet.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.flxrs.dankchat.ui.main.sheet - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.R - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MoreActionsSheet( - messageId: String, - fullMessage: String, - onCopyFullMessage: (String) -> Unit, - onCopyMessageId: (String) -> Unit, - onDismiss: () -> Unit, - sheetState: SheetState, -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - ListItem( - headlineContent = { Text(stringResource(R.string.message_copy_full)) }, - leadingContent = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, - modifier = Modifier.clickable { - onCopyFullMessage(fullMessage) - onDismiss() - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent) - ) - ListItem( - headlineContent = { Text(stringResource(R.string.message_copy_id)) }, - leadingContent = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, - modifier = Modifier.clickable { - onCopyMessageId(messageId) - onDismiss() - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent) - ) - } - } -} 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 index b7208717c..3ed715bcc 100644 --- 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 @@ -51,10 +51,6 @@ class SheetNavigationViewModel : ViewModel() { _inputSheetState.value = InputSheetState.EmoteMenu } - fun openMoreActions(messageId: String, fullMessage: String) { - _inputSheetState.value = InputSheetState.MoreActions(messageId, fullMessage) - } - fun openDebugInfo() { _inputSheetState.value = InputSheetState.DebugInfo } @@ -96,9 +92,6 @@ sealed interface InputSheetState { data object Closed : InputSheetState data object EmoteMenu : InputSheetState data object DebugInfo : InputSheetState - - @Immutable - data class MoreActions(val messageId: String, val fullMessage: String) : InputSheetState } @Immutable 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 index 62ec17593..52fcb951a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt @@ -45,6 +45,7 @@ fun InfoBottomSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { InfoSheetContent(title, message, confirmText, dismissText, onConfirm, onDismiss) } @@ -70,7 +71,7 @@ fun InfoBottomSheet( Surface( shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), shadowElevation = 8.dp, - color = MaterialTheme.colorScheme.surfaceContainerLow, + color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier.fillMaxWidth(), ) { Column( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt index 45bc09fbd..6ab69aac7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt @@ -88,7 +88,7 @@ fun StyledBottomSheet( } .shadow(8.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow), + .background(MaterialTheme.colorScheme.surfaceContainerHigh), ) { Column( modifier = Modifier From f0169c6099a94ad3476fabe34478e7c84cef8c94 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 14:22:15 +0200 Subject: [PATCH 153/349] fix(ui): Replace navigation transitions with scale+fade exit and slide+fade enter --- .../flxrs/dankchat/ui/main/MainActivity.kt | 167 ++++++++---------- 1 file changed, 74 insertions(+), 93 deletions(-) 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 index 114cea7d8..f7a0a6da0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -23,8 +23,8 @@ 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.animation.slideOutHorizontally import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -34,6 +34,7 @@ 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 @@ -217,10 +218,8 @@ class MainActivity : AppCompatActivity() { ) } composable
( - enterTransition = { slideInHorizontally(initialOffsetX = { -it }) }, - exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) }, - popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) }, - popExitTransition = { slideOutHorizontally(targetOffsetX = { -it }) } + 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( navController = navController, @@ -288,34 +287,10 @@ class MainActivity : AppCompatActivity() { ) } composable( - enterTransition = { - if (initialState.destination.route?.contains("Main") == true) { - slideInHorizontally(initialOffsetX = { it }) - } else { - fadeIn(animationSpec = tween(220, delayMillis = 90)) - } - }, - exitTransition = { - if (targetState.destination.route?.contains("Main") == true) { - slideOutHorizontally(targetOffsetX = { it }) - } else { - fadeOut(animationSpec = tween(90)) - } - }, - popEnterTransition = { - if (initialState.destination.route?.contains("Main") == true) { - slideInHorizontally(initialOffsetX = { it }) - } else { - fadeIn(animationSpec = tween(220, delayMillis = 90)) - } - }, - popExitTransition = { - if (targetState.destination.route?.contains("Main") == true) { - slideOutHorizontally(targetOffsetX = { it }) - } else { - fadeOut(animationSpec = tween(90)) - } - } + 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, @@ -342,28 +317,34 @@ class MainActivity : AppCompatActivity() { ) } - val settingsEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { - fadeIn(animationSpec = tween(220, delayMillis = 90)) + 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 settingsExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { - fadeOut(animationSpec = tween(90)) + val subPopExit: @JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition = { + scaleOut(targetScale = 0.92f, animationSpec = tween(300)) + fadeOut(animationSpec = tween(200)) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { AppearanceSettingsScreen( onBackPressed = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { NotificationsSettingsScreen( onNavToHighlights = { navController.navigate(HighlightsSettings) }, @@ -372,30 +353,30 @@ class MainActivity : AppCompatActivity() { ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { HighlightsScreen( onNavBack = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { IgnoresScreen( onNavBack = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { ChatSettingsScreen( onNavToCommands = { navController.navigate(CustomCommandsSettings) }, @@ -404,40 +385,40 @@ class MainActivity : AppCompatActivity() { ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { CustomCommandsScreen( onNavBack = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { UserDisplayScreen( onNavBack = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { StreamsSettingsScreen( onBackPressed = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { ToolsSettingsScreen( onNavToImageUploader = { navController.navigate(ImageUploaderSettings) }, @@ -446,50 +427,50 @@ class MainActivity : AppCompatActivity() { ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { ImageUploaderScreen( onNavBack = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { TTSUserIgnoreListScreen( onNavBack = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { DeveloperSettingsScreen( onBackPressed = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { ChangelogScreen( onBackPressed = { navController.popBackStack() } ) } composable( - enterTransition = settingsEnterTransition, - exitTransition = settingsExitTransition, - popEnterTransition = settingsEnterTransition, - popExitTransition = settingsExitTransition + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit ) { AboutScreen( onBackPressed = { navController.popBackStack() } From cb0ddd3048d5c2956ebd80ff5722b25b3ae1af7e Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 14:24:49 +0200 Subject: [PATCH 154/349] style: Reformat code, reorder imports, remove unused imports --- .../com/flxrs/dankchat/DankChatApplication.kt | 2 +- .../data/api/eventapi/EventSubClient.kt | 8 +- .../data/api/eventapi/EventSubManager.kt | 1 - .../data/api/eventapi/EventSubTopic.kt | 2 +- .../dankchat/data/api/helix/HelixApiClient.kt | 4 +- .../data/auth/AuthStateCoordinator.kt | 6 +- .../data/repo/chat/ChatEventProcessor.kt | 12 +- .../data/repo/chat/ChatMessageSender.kt | 15 +- .../data/repo/emote/EmojiRepository.kt | 2 +- .../data/twitch/pubsub/PubSubManager.kt | 4 +- .../dankchat/domain/ConnectionCoordinator.kt | 1 - .../developer/DeveloperSettingsScreen.kt | 2 +- .../preferences/tools/ToolsSettingsScreen.kt | 2 +- .../tools/upload/ImageUploaderScreen.kt | 2 +- .../dankchat/ui/chat/ChatMessageMapper.kt | 2 +- .../ui/chat/messages/AutomodMessage.kt | 12 +- .../ui/chat/messages/common/InlineContent.kt | 10 +- .../messages/common/MessageTextBuilders.kt | 4 +- .../dankchat/ui/chat/user/UserPopupDialog.kt | 3 +- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 233 +++++++++--------- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 63 +++-- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 9 +- .../ui/main/MainScreenEventHandler.kt | 4 +- .../dankchat/ui/main/MainScreenViewModel.kt | 4 +- .../dankchat/ui/main/QuickActionsMenu.kt | 6 +- .../ui/main/dialog/MainScreenDialogs.kt | 18 +- .../ui/main/dialog/ManageChannelsDialog.kt | 10 +- .../ui/main/dialog/MessageOptionsDialog.kt | 21 +- .../ui/main/dialog/ModActionsDialog.kt | 20 +- .../dankchat/ui/main/input/ChatInputLayout.kt | 25 +- .../ui/main/input/ChatInputViewModel.kt | 3 +- .../ui/main/sheet/FullScreenSheetOverlay.kt | 2 +- .../utils/compose/BottomSheetNestedScroll.kt | 2 +- .../dankchat/utils/compose/InfoBottomSheet.kt | 2 +- .../utils/compose/InputBottomSheet.kt | 6 +- .../utils/compose/StyledBottomSheet.kt | 4 - 36 files changed, 253 insertions(+), 273 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index e75ca0dbe..54bf08b13 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -14,8 +14,8 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory import com.flxrs.dankchat.data.repo.HighlightsRepository import com.flxrs.dankchat.data.repo.IgnoresRepository import com.flxrs.dankchat.di.DankChatModule -import com.flxrs.dankchat.domain.ConnectionCoordinator 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 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 c477220eb..f5237f2c8 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 @@ -264,28 +264,28 @@ class EventSubClient( private fun handleNotification(message: NotificationMessageDto) { Log.d(TAG, "[EventSub] received notification message: $message") val eventSubMessage = when (val event = message.payload.event) { - is ChannelModerateDto -> ModerationAction( + is ChannelModerateDto -> ModerationAction( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is AutomodMessageHoldDto -> AutomodHeld( + is AutomodMessageHoldDto -> AutomodHeld( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is AutomodMessageUpdateDto -> AutomodUpdate( + is AutomodMessageUpdateDto -> AutomodUpdate( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is ChannelChatUserMessageHoldDto -> UserMessageHeld( + is ChannelChatUserMessageHoldDto -> UserMessageHeld( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, 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 a7b060574..d30f3ac9f 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 @@ -6,7 +6,6 @@ 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 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 686ba2c95..98ee3b710 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,8 +2,8 @@ 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.EventSubMethod 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 import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionType 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 e1193af91..2507603b3 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 @@ -17,12 +17,12 @@ 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.SendChatMessageRequestDto -import com.flxrs.dankchat.data.api.helix.dto.SendChatMessageResponseDto 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 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 index 23d6978f9..c5c55daf0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -114,10 +114,10 @@ class AuthStateCoordinator( startupValidationHolder.update( when (result) { is AuthEvent.LoggedIn, - is AuthEvent.ValidationFailed -> StartupValidation.Validated + is AuthEvent.ValidationFailed -> StartupValidation.Validated - is AuthEvent.ScopesOutdated -> StartupValidation.ScopesOutdated(result.userName) - AuthEvent.TokenInvalid -> StartupValidation.TokenInvalid + is AuthEvent.ScopesOutdated -> StartupValidation.ScopesOutdated(result.userName) + AuthEvent.TokenInvalid -> StartupValidation.TokenInvalid } ) 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 index 5f8703273..cbd01a549 100644 --- 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 @@ -32,10 +32,10 @@ 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.toDebugChatItem 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 @@ -134,12 +134,12 @@ class ChatEventProcessor( 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 ModerationAction -> handleEventSubModeration(eventMessage) + is AutomodHeld -> handleAutomodHeld(eventMessage) + is AutomodUpdate -> handleAutomodUpdate(eventMessage) + is UserMessageHeld -> handleUserMessageHeld(eventMessage) is UserMessageUpdated -> handleUserMessageUpdated(eventMessage) - is SystemMessage -> postEventSubDebugMessage(eventMessage.message) + is SystemMessage -> postEventSubDebugMessage(eventMessage.message) } } } 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 index 29466ec1e..622ce174f 100644 --- 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 @@ -79,8 +79,7 @@ class ChatMessageSender( } else -> { - val reason = response.dropReason - val msg = when (reason) { + val msg = when (val reason = response.dropReason) { null -> "Message was not sent." else -> "Message dropped: ${reason.message} (${reason.code})" } @@ -117,14 +116,14 @@ class ChatMessageSender( is HelixApiException -> when (error) { HelixError.NotLoggedIn -> "Not logged in." HelixError.MissingScopes -> "Missing user:write:chat scope. Please re-login." - HelixError.UserNotAuthorized -> "Not authorized to send messages in this channel." - HelixError.MessageTooLarge -> "Message is too large." - HelixError.ChatMessageRateLimited -> "Rate limited. Try again in a moment." - HelixError.Forwarded -> message ?: "Unknown error." - else -> message ?: "Unknown error." + HelixError.UserNotAuthorized -> "Not authorized to send messages in this channel." + HelixError.MessageTooLarge -> "Message is too large." + HelixError.ChatMessageRateLimited -> "Rate limited. Try again in a moment." + HelixError.Forwarded -> message ?: "Unknown error." + else -> message ?: "Unknown error." } - else -> message ?: "Unknown error." + else -> message ?: "Unknown error." } companion object { 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 index 7922daad4..d63c4a62b 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -9,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.koin.core.annotation.Single 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 63b46cc60..c46a98a64 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 @@ -4,10 +4,10 @@ import android.util.Log 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.toUserId 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.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix @@ -16,7 +16,6 @@ import io.ktor.client.plugins.websocket.WebSockets import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel as CoroutineChannel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.koin.core.annotation.Single +import kotlinx.coroutines.channels.Channel as CoroutineChannel @Single class PubSubManager( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt index 0187f29e0..75ea3c228 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt @@ -27,7 +27,6 @@ class ConnectionCoordinator( private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - fun initialize() { scope.launch { val result = authStateCoordinator.validateOnStartup() 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 index 71f57f1ed..1d7ed9b2f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -27,7 +27,6 @@ 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 com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -76,6 +75,7 @@ import com.flxrs.dankchat.preferences.developer.DeveloperSettingsInteraction.Eve 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 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 6eae2e34a..218d790ab 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 com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -75,6 +74,7 @@ import com.flxrs.dankchat.preferences.components.PreferenceListDialog 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 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 86540759c..43d1b310f 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,7 +20,6 @@ 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 com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -51,6 +50,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 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 index fa4bae5b6..c680dd107 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -143,7 +143,7 @@ class ChatMessageMapper( 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 -> TextResource.Plain(type.message) - is SystemMessageType.Debug -> TextResource.Plain(type.message) + is SystemMessageType.Debug -> TextResource.Plain(type.message) 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)) 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 index 52c30c37e..f573659aa 100644 --- 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 @@ -110,13 +110,13 @@ fun AutomodMessageComposable( } // Mod-side: reason text + Allow/Deny buttons or status - else -> { + else -> { withStyle(SpanStyle(color = textColor)) { append("$headerText ") } when (message.status) { - AutomodMessageStatus.Pending -> { + AutomodMessageStatus.Pending -> { pushStringAnnotation(tag = ALLOW_TAG, annotation = message.heldMessageId) withStyle(SpanStyle(color = allowColor, fontWeight = FontWeight.Bold)) { append(allowText) @@ -136,13 +136,13 @@ fun AutomodMessageComposable( } } - AutomodMessageStatus.Denied -> { + AutomodMessageStatus.Denied -> { withStyle(SpanStyle(color = denyColor.copy(alpha = 0.6f), fontWeight = FontWeight.Bold)) { append(deniedText) } } - AutomodMessageStatus.Expired -> { + AutomodMessageStatus.Expired -> { withStyle(SpanStyle(color = textColor.copy(alpha = 0.5f), fontWeight = FontWeight.Bold)) { append(expiredText) } @@ -209,9 +209,9 @@ fun AutomodMessageComposable( } val resolvedAlpha = when { - message.isUserSide -> 1f + message.isUserSide -> 1f message.status == AutomodMessageStatus.Pending -> 1f - else -> 0.5f + else -> 0.5f } Column( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt index 1cb59c65b..7f1a22815 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt @@ -30,10 +30,12 @@ fun BadgeInlineContent( modifier: Modifier = Modifier ) { when (badge.badge) { - is Badge.FFZModBadge -> { - Box(modifier = modifier - .size(size) - .background(FfzModGreen)) { + is Badge.FFZModBadge -> { + Box( + modifier = modifier + .size(size) + .background(FfzModGreen) + ) { AsyncImage( model = badge.url, contentDescription = badge.badge.type.name, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt index 500d9cddb..22d576085 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt @@ -143,14 +143,14 @@ data class UserAnnotation( fun parseUserAnnotation(annotation: String): UserAnnotation? { val parts = annotation.split("|") return when (parts.size) { - 4 -> UserAnnotation( + 4 -> UserAnnotation( userId = parts[0].takeIf { it.isNotEmpty() }, userName = parts[1], displayName = parts[2], channel = parts[3], ) - 3 -> UserAnnotation( + 3 -> UserAnnotation( userId = parts[0].takeIf { it.isNotEmpty() }, userName = parts[1], displayName = parts[2], 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 index b090ecf50..4602b2d36 100644 --- 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 @@ -28,16 +28,15 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Report import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.OutlinedButton 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.TextButton import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults 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 index 31a242292..883545503 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -11,15 +11,12 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.ScrollState 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.fillMaxHeight -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -27,23 +24,24 @@ 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.height 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.rememberScrollState 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.filled.ArrowDropDown import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -61,9 +59,9 @@ import androidx.compose.material3.TooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableIntStateOf 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 @@ -85,12 +83,11 @@ 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.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -105,11 +102,9 @@ 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 kotlin.coroutines.cancellation.CancellationException -import kotlinx.coroutines.launch -import kotlin.math.abs import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first +import kotlin.coroutines.cancellation.CancellationException sealed interface ToolbarAction { data class SelectTab(val index: Int) : ToolbarAction @@ -164,7 +159,7 @@ fun FloatingToolbar( val totalTabs = tabState.tabs.size val selectedIndex = composePagerState.currentPage val tabScrollState = rememberScrollState() - val coroutineScope = rememberCoroutineScope() + rememberCoroutineScope() val hasOverflow by remember { derivedStateOf { tabScrollState.maxValue > 0 } } @@ -343,9 +338,9 @@ fun FloatingToolbar( tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex val textColor = when { - isSelected -> MaterialTheme.colorScheme.primary + isSelected -> MaterialTheme.colorScheme.primary tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurfaceVariant } Row( verticalAlignment = Alignment.CenterVertically, @@ -363,7 +358,7 @@ fun FloatingToolbar( .padding(horizontal = 12.dp) .onGloballyPositioned { coords -> val offsets = tabOffsets.value - val widths = tabWidths.value + tabWidths.value if (offsets.size != totalTabs) { tabOffsets.value = IntArray(totalTabs) tabWidths.value = IntArray(totalTabs) @@ -527,127 +522,127 @@ fun FloatingToolbar( // Action icons + inline overflow menu Row(verticalAlignment = Alignment.Top) { - Spacer(Modifier.width(8.dp)) + 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)) + 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) + ) } - 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() } - if (addChannelTooltipState != null) { - LaunchedEffect(Unit) { - addChannelTooltipState.show() - } - LaunchedEffect(Unit) { - snapshotFlow { addChannelTooltipState.isVisible } - .dropWhile { !it } // skip initial false - .first { !it } // wait for dismiss (any cause) - onAddChannelTooltipDismissed() - } - 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(); onAddChannelTooltipDismissed(); onSkipTour() }) { - Text(stringResource(R.string.tour_skip)) - } - TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed() }) { - Text(stringResource(R.string.tour_next)) - } + LaunchedEffect(Unit) { + snapshotFlow { addChannelTooltipState.isVisible } + .dropWhile { !it } // skip initial false + .first { !it } // wait for dismiss (any cause) + onAddChannelTooltipDismissed() + } + 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(); onAddChannelTooltipDismissed(); onSkipTour() }) { + Text(stringResource(R.string.tour_skip)) + } + TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed() }) { + Text(stringResource(R.string.tour_next)) } } - ) { - Text(stringResource(R.string.tour_add_more_channels_hint)) } - }, - state = addChannelTooltipState, - hasAction = true, - ) { - addChannelIcon() - } - } else { + ) { + Text(stringResource(R.string.tour_add_more_channels_hint)) + } + }, + state = addChannelTooltipState, + hasAction = true, + ) { addChannelIcon() } - if (isLoggedIn) { - IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { - Icon( - imageVector = Icons.Default.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 - }) { + } else { + addChannelIcon() + } + if (isLoggedIn) { + IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more) + imageVector = Icons.Default.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 + }) { + 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(), + AnimatedVisibility( + visible = showOverflowMenu, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = Modifier + .skipIntrinsicHeight() + .padding(top = 4.dp) + .endAlignedOverflow(), + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, ) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - InlineOverflowMenu( - isLoggedIn = isLoggedIn, - onDismiss = { - showOverflowMenu = false - overflowInitialMenu = AppBarMenu.Main - }, - initialMenu = overflowInitialMenu, - onAction = onAction, - keyboardHeightDp = keyboardHeightDp, - ) - } + InlineOverflowMenu( + isLoggedIn = isLoggedIn, + onDismiss = { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + }, + initialMenu = overflowInitialMenu, + onAction = onAction, + keyboardHeightDp = keyboardHeightDp, + ) } } } + } } } } 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 index 1d2e04f8a..d396c5750 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -31,7 +31,6 @@ 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.DeleteSweep import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.Flag @@ -144,48 +143,48 @@ fun InlineOverflowMenu( .padding(vertical = 8.dp), ) { when (menu) { - AppBarMenu.Main -> { - if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { onAction(ToolbarAction.Login); onDismiss() } - } else { - InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { onAction(ToolbarAction.Relogin); onDismiss() } - InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { onAction(ToolbarAction.Logout); onDismiss() } - } + AppBarMenu.Main -> { + if (!isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { onAction(ToolbarAction.Login); onDismiss() } + } else { + InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { onAction(ToolbarAction.Relogin); onDismiss() } + InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { onAction(ToolbarAction.Logout); onDismiss() } + } - HorizontalDivider() + HorizontalDivider() - InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { onAction(ToolbarAction.ManageChannels); onDismiss() } - InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { onAction(ToolbarAction.RemoveChannel); onDismiss() } - InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { onAction(ToolbarAction.ReloadEmotes); onDismiss() } - InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { onAction(ToolbarAction.Reconnect); onDismiss() } + InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { onAction(ToolbarAction.ManageChannels); onDismiss() } + InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { onAction(ToolbarAction.RemoveChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { onAction(ToolbarAction.ReloadEmotes); onDismiss() } + InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { onAction(ToolbarAction.Reconnect); onDismiss() } - HorizontalDivider() + HorizontalDivider() - InlineMenuItem(text = stringResource(R.string.upload_media), icon = Icons.Default.CloudUpload, hasSubMenu = true) { currentMenu = AppBarMenu.Upload } - InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true) { currentMenu = AppBarMenu.Channel } + InlineMenuItem(text = stringResource(R.string.upload_media), icon = Icons.Default.CloudUpload, hasSubMenu = true) { currentMenu = AppBarMenu.Upload } + InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true) { currentMenu = AppBarMenu.Channel } - HorizontalDivider() + HorizontalDivider() - InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { onAction(ToolbarAction.OpenSettings); onDismiss() } - } + InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { onAction(ToolbarAction.OpenSettings); onDismiss() } + } - AppBarMenu.Upload -> { - InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { onAction(ToolbarAction.CaptureImage); onDismiss() } - InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { onAction(ToolbarAction.CaptureVideo); onDismiss() } - InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { onAction(ToolbarAction.ChooseMedia); onDismiss() } - } + AppBarMenu.Upload -> { + InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { onAction(ToolbarAction.CaptureImage); onDismiss() } + InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { onAction(ToolbarAction.CaptureVideo); onDismiss() } + InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { onAction(ToolbarAction.ChooseMedia); onDismiss() } + } - AppBarMenu.Channel -> { - InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { onAction(ToolbarAction.OpenChannel); onDismiss() } - InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { onAction(ToolbarAction.ReportChannel); onDismiss() } - if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { onAction(ToolbarAction.BlockChannel); onDismiss() } + AppBarMenu.Channel -> { + InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { onAction(ToolbarAction.OpenChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { onAction(ToolbarAction.ReportChannel); onDismiss() } + if (isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { onAction(ToolbarAction.BlockChannel); onDismiss() } + } } } } - } } if (scrollState.maxValue > 0) { VerticalScrollbar( 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 index 1e6ad9d68..0d4c2c6db 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -47,12 +47,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -116,8 +113,8 @@ 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.InputOverlay 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.EmoteMenu @@ -566,7 +563,7 @@ fun MainScreen( is FullScreenSheetState.Whisper, is FullScreenSheetState.Mention -> when { inputState.isWhisperTabActive && inputState.overlay is InputOverlay.Whisper -> persistentListOf(InputAction.LastMessage) - else -> persistentListOf() + else -> persistentListOf() } is FullScreenSheetState.History, @@ -588,7 +585,7 @@ fun MainScreen( swipeGestureTooltipState = if (featureTourState.currentTourStep == TourStep.SwipeGesture) featureTourViewModel.swipeGestureTooltipState else null, forceOverflowOpen = featureTourState.forceOverflowOpen, isTourActive = featureTourState.isTourActive - || featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint, + || featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint, onAdvance = featureTourViewModel::advance, onSkip = featureTourViewModel::skipTour, ) 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 index f43669497..b801347b0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -10,11 +10,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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 androidx.core.content.getSystemService import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.data.auth.AuthEvent import com.flxrs.dankchat.data.auth.AuthStateCoordinator 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 index 1bcc36514..feacb1816 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -90,11 +90,11 @@ class MainScreenViewModel( appearance.copy(inputActions = actions + InputAction.Debug) } - !enabled && InputAction.Debug in actions -> { + !enabled && InputAction.Debug in actions -> { appearance.copy(inputActions = actions - InputAction.Debug) } - else -> appearance + else -> appearance } } } 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 index c31300ee2..b27f99c44 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -181,7 +181,7 @@ private fun getOverflowItem( else -> null } - InputAction.ModActions -> when { + InputAction.ModActions -> when { isModerator -> OverflowItem( labelRes = R.string.menu_mod_actions, icon = Icons.Default.Shield, @@ -208,8 +208,8 @@ private fun getOverflowItem( 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 + InputAction.LastMessage -> inputEnabled && hasLastMessage + InputAction.Stream, InputAction.ModActions -> inputEnabled } /** 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 index 0563264ac..fc50798e0 100644 --- 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 @@ -1,7 +1,6 @@ package com.flxrs.dankchat.ui.main.dialog import android.content.ClipData -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -9,11 +8,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -36,10 +34,6 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.auth.StartupValidation import com.flxrs.dankchat.data.auth.StartupValidationHolder import com.flxrs.dankchat.ui.chat.BadgeUi -import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet -import com.flxrs.dankchat.utils.compose.InfoBottomSheet -import com.flxrs.dankchat.utils.compose.InputBottomSheet -import com.flxrs.dankchat.utils.compose.StyledBottomSheet import com.flxrs.dankchat.ui.chat.emote.EmoteInfoViewModel import com.flxrs.dankchat.ui.chat.message.MessageOptionsState import com.flxrs.dankchat.ui.chat.message.MessageOptionsViewModel @@ -47,11 +41,15 @@ import com.flxrs.dankchat.ui.chat.user.UserPopupDialog import com.flxrs.dankchat.ui.chat.user.UserPopupViewModel import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel import com.flxrs.dankchat.ui.main.input.ChatInputViewModel -import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState -import com.flxrs.dankchat.ui.main.sheet.InputSheetState 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 com.flxrs.dankchat.utils.compose.StyledBottomSheet import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -351,7 +349,7 @@ private fun UploadDisclaimerSheet( onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - val uriHandler = LocalUriHandler.current + LocalUriHandler.current val disclaimerTemplate = stringResource(R.string.external_upload_disclaimer, host) val hostStart = disclaimerTemplate.indexOf(host) val annotatedText = buildAnnotatedString { 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 index 23d3d6bec..d6f6e1206 100644 --- 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 @@ -8,14 +8,14 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +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.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -29,11 +29,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState 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 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 index 3f3318b45..46c60c5ce 100644 --- 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 @@ -1,22 +1,20 @@ 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.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -29,16 +27,15 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.OutlinedButton 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.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -48,8 +45,8 @@ 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.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 @@ -105,7 +102,7 @@ fun MessageOptionsDialog( label = "MessageOptionsContent" ) { currentView -> when (currentView) { - null -> MessageOptionsMainView( + null -> MessageOptionsMainView( canReply = canReply, canJump = canJump, canCopy = canCopy, @@ -261,7 +258,7 @@ private fun TimeoutSubView( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), + .padding(bottom = 16.dp), ) { Text( text = stringResource(R.string.confirm_user_timeout_title), @@ -313,7 +310,7 @@ private fun ConfirmationSubView( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), + .padding(bottom = 16.dp), ) { Text( text = title, 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 index 3781a9a5d..836e66865 100644 --- 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 @@ -8,16 +8,15 @@ 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.Row -import androidx.compose.foundation.layout.Spacer 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.ui.Alignment import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -25,35 +24,36 @@ 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.Button -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button 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.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalDensity 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.style.TextAlign 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 @@ -182,7 +182,7 @@ fun ModActionsDialog( onDismiss = onDismiss, ) - SubView.CommercialPresets -> PresetChips( + SubView.CommercialPresets -> PresetChips( titleRes = R.string.mod_actions_commercial, presets = COMMERCIAL_PRESETS, formatLabel = { stringResource(R.string.room_state_duration_seconds, it) }, 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 index 03862f194..de06b250a 100644 --- 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 @@ -71,9 +71,9 @@ 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.mutableStateListOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -81,9 +81,9 @@ 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.pointer.pointerInput 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.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -94,13 +94,12 @@ 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.data.UserName import com.flxrs.dankchat.preferences.appearance.InputAction import com.flxrs.dankchat.ui.main.InputState import com.flxrs.dankchat.ui.main.QuickActionsMenu import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CancellationException import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import sh.calvin.reorderable.ReorderableColumn private const val MAX_INPUT_ACTIONS = 4 @@ -207,10 +206,10 @@ fun ChatInputLayout( val effectiveActions = remember(inputActions, isModerator, hasStreamData, isStreamActive, debugMode) { inputActions.filter { action -> when (action) { - InputAction.Stream -> hasStreamData || isStreamActive + InputAction.Stream -> hasStreamData || isStreamActive InputAction.ModActions -> isModerator - InputAction.Debug -> debugMode - else -> true + InputAction.Debug -> debugMode + else -> true } }.toImmutableList() } @@ -427,7 +426,7 @@ fun ChatInputLayout( InputAction.Search -> onSearchClick() InputAction.LastMessage -> onLastMessageClick() InputAction.Stream -> onToggleStream() - InputAction.ModActions -> onModActions() + InputAction.ModActions -> onModActions() InputAction.Fullscreen -> onToggleFullscreen() InputAction.HideInput -> onToggleInput() InputAction.Debug -> onDebugInfoClick() @@ -594,7 +593,7 @@ private val InputAction.labelRes: Int 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.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 @@ -605,7 +604,7 @@ private val InputAction.icon: ImageVector InputAction.Search -> Icons.Default.Search InputAction.LastMessage -> Icons.Default.History InputAction.Stream -> Icons.Default.Videocam - InputAction.ModActions -> Icons.Default.Shield + InputAction.ModActions -> Icons.Default.Shield InputAction.Fullscreen -> Icons.Default.Fullscreen InputAction.HideInput -> Icons.Default.VisibilityOff InputAction.Debug -> Icons.Default.BugReport @@ -685,7 +684,7 @@ private fun InputActionButton( onToggleStream, ) - InputAction.ModActions -> Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) + InputAction.ModActions -> Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) InputAction.Fullscreen -> Triple( if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, R.string.toggle_fullscreen, @@ -698,8 +697,8 @@ private fun InputActionButton( val actionEnabled = when (action) { InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true - InputAction.LastMessage -> enabled && hasLastMessage - InputAction.Stream, InputAction.ModActions -> enabled + InputAction.LastMessage -> enabled && hasLastMessage + InputAction.Stream, InputAction.ModActions -> enabled } IconButton( 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 index fb043e642..8e73db573 100644 --- 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 @@ -260,6 +260,7 @@ class ChatInputViewModel( ) { values -> val sheetState = values[0] as FullScreenSheetState val tab = values[1] as Int + @Suppress("UNCHECKED_CAST") val replyState = values[2] as Triple val isEmoteMenuOpen = values[3] as Boolean @@ -384,7 +385,7 @@ class ChatInputViewModel( is CommandResult.Accepted, is CommandResult.Blocked -> Unit - is CommandResult.IrcCommand -> { + is CommandResult.IrcCommand -> { chatRepository.sendMessage(message, replyIdOrNull, forceIrc = true) setReplying(false) } 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 index 789c038bb..0f806a956 100644 --- 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 @@ -15,8 +15,8 @@ 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.preferences.chat.UserLongClickBehavior 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.MessageHistoryViewModel import com.flxrs.dankchat.ui.chat.mention.MentionViewModel 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 index 3508ede81..08daad24d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt @@ -19,7 +19,7 @@ object BottomSheetNestedScrollConnection : NestedScrollConnection { override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = when (source) { NestedScrollSource.SideEffect -> available.copy(x = 0f) - else -> Offset.Zero + else -> Offset.Zero } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = 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 index 52fcb951a..e18ae6070 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt @@ -26,8 +26,8 @@ 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.composables.core.rememberModalBottomSheetState as rememberUnstyledSheetState import com.flxrs.dankchat.R +import com.composables.core.rememberModalBottomSheetState as rememberUnstyledSheetState @OptIn(ExperimentalMaterial3Api::class) @Composable 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 index 343401844..62fa7a2aa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt @@ -7,13 +7,13 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +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.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt index 6ab69aac7..79c75dcbb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt @@ -19,11 +19,8 @@ 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.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.draw.clip @@ -37,7 +34,6 @@ import com.composables.core.Scrim import com.composables.core.Sheet import com.composables.core.SheetDetent import com.composables.core.rememberModalBottomSheetState -import kotlinx.coroutines.flow.distinctUntilChanged import java.util.concurrent.CancellationException @Composable From 5f0240110a0c033413939dba495e87cc3b8646b9 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 15:07:16 +0200 Subject: [PATCH 155/349] fix(ui): Use translated TextResource for room state display, add two-line helper text layout, prioritize stream data fetch before emote pagination --- .../data/debug/ChannelDebugSection.kt | 2 +- .../data/repo/stream/StreamDataRepository.kt | 42 +++++++------ .../dankchat/data/twitch/message/RoomState.kt | 37 +++++++++-- .../dankchat/domain/ChannelDataCoordinator.kt | 9 +++ .../com/flxrs/dankchat/ui/main/MainScreen.kt | 2 +- .../dankchat/ui/main/input/ChatBottomBar.kt | 8 ++- .../dankchat/ui/main/input/ChatInputLayout.kt | 62 ++++++++++++++++--- .../ui/main/input/ChatInputUiState.kt | 56 +++++++++++++++++ .../ui/main/input/ChatInputViewModel.kt | 59 ++++-------------- app/src/main/res/values/strings.xml | 2 + 10 files changed, 193 insertions(+), 86 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputUiState.kt 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 index 3c8f24de9..2590a66f2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt @@ -30,7 +30,7 @@ class ChannelDebugSection( when (roomState) { null -> add(DebugEntry("Room state", "Unknown")) else -> { - val display = roomState.toDisplayText() + val display = roomState.toDebugText() add(DebugEntry("Room state", display.ifEmpty { "None" })) } } 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 index df27f5f9c..707dd6cca 100644 --- 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 @@ -48,29 +48,33 @@ class StreamDataRepository( } fetchTimerJob = timer(STREAM_REFRESH_RATE) { - val currentSettings = streamsSettingsDataStore.settings.first() - _fetchCount.incrementAndGet() - 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() + fetchOnce(channels) } } } + suspend fun fetchOnce(channels: List) { + val currentSettings = streamsSettingsDataStore.settings.first() + _fetchCount.incrementAndGet() + 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 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..d714de624 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,8 +1,14 @@ package com.flxrs.dankchat.data.twitch.message +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 data class RoomState( val channel: UserName, @@ -29,16 +35,35 @@ 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) }, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index cfcd83e29..6fb776d68 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -9,6 +9,7 @@ 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.state.ChannelLoadingState +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.data.twitch.message.SystemMessageType import com.flxrs.dankchat.di.DispatchersProvider @@ -36,6 +37,7 @@ class ChannelDataCoordinator( private val authDataStore: AuthDataStore, private val preferenceStore: DankChatPreferenceStore, private val startupValidationHolder: StartupValidationHolder, + private val streamDataRepository: StreamDataRepository, dispatchersProvider: DispatchersProvider ) { @@ -123,6 +125,13 @@ class ChannelDataCoordinator( // 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() 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 index 0d4c2c6db..d3516bd7d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -418,7 +418,7 @@ fun MainScreen( var helperTextHeightPx by remember { mutableIntStateOf(0) } var inputOverflowExpanded by remember { mutableStateOf(false) } if (!effectiveShowInput) inputHeightPx = 0 - if (effectiveShowInput || inputState.helperText.isNullOrEmpty()) helperTextHeightPx = 0 + if (effectiveShowInput || inputState.helperText.isEmpty) helperTextHeightPx = 0 val inputHeightDp = with(density) { inputHeightPx.toDp() } val helperTextHeightDp = with(density) { helperTextHeightPx.toDp() } // scaffoldBottomContentPadding removed — input bar rendered outside Scaffold 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 index a80b6f650..af7b6c161 100644 --- 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 @@ -25,6 +25,7 @@ import androidx.compose.ui.text.style.TextAlign 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) @@ -87,8 +88,11 @@ fun ChatBottomBar( // Sticky helper text + nav bar spacer when input is hidden if (!showInput && !isSheetOpen) { - val helperText = uiState.helperText - if (!helperText.isNullOrEmpty()) { + val helperTextState = uiState.helperText + val resolvedRoomState = helperTextState.roomStateParts.map { it.resolve() } + val roomStateText = resolvedRoomState.joinToString(separator = ", ") + val helperText = listOfNotNull(roomStateText.ifEmpty { null }, helperTextState.streamInfo).joinToString(separator = " - ") + if (helperText.isNotEmpty()) { val horizontalPadding = when { isFullscreen && isInSplitLayout -> { val rcPadding = rememberRoundedCornerHorizontalPadding(fallback = 16.dp) 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 index de06b250a..12878c5f3 100644 --- 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 @@ -4,6 +4,7 @@ import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.animateContentSize import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -94,6 +95,10 @@ 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.utils.resolve +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Constraints import com.flxrs.dankchat.preferences.appearance.InputAction import com.flxrs.dankchat.ui.main.InputState import com.flxrs.dankchat.ui.main.QuickActionsMenu @@ -158,7 +163,7 @@ fun ChatInputLayout( val hasLastMessage = uiState.hasLastMessage val canSend = uiState.canSend val isEmoteMenuOpen = uiState.isEmoteMenuOpen - val helperText = if (isSheetOpen) null else uiState.helperText + val helperText = if (isSheetOpen) HelperText() else uiState.helperText val overlay = uiState.overlay val characterCounter = uiState.characterCounter val showQuickActions = !isSheetOpen @@ -306,23 +311,60 @@ fun ChatInputLayout( ) // Helper text (roomstate + live info) + val resolvedRoomState = helperText.roomStateParts.map { it.resolve() } + val roomStateText = resolvedRoomState.joinToString(separator = ", ") + val streamInfoText = helperText.streamInfo AnimatedVisibility( - visible = !helperText.isNullOrEmpty(), + visible = !helperText.isEmpty, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { - Text( - text = helperText.orEmpty(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + val combinedText = listOfNotNull(roomStateText.ifEmpty { null }, streamInfoText).joinToString(separator = " - ") + val textMeasurer = rememberTextMeasurer() + val style = MaterialTheme.typography.labelSmall + val density = LocalDensity.current + BoxWithConstraints( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 4.dp) - .basicMarquee(), - textAlign = TextAlign.Start - ) + .animateContentSize(), + ) { + val maxWidthPx = with(density) { maxWidth.roundToPx() } + val fitsOnOneLine = remember(combinedText, style, maxWidthPx) { + textMeasurer.measure(combinedText, style).size.width <= maxWidthPx + } + when { + fitsOnOneLine || streamInfoText == null || roomStateText.isEmpty() -> { + Text( + text = combinedText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(), + ) + } + + else -> { + Column { + Text( + text = roomStateText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(), + ) + Text( + text = streamInfoText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(), + ) + } + } + } + } } // Progress indicator for uploads and data loading 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..ebebc5249 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputUiState.kt @@ -0,0 +1,56 @@ +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 userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, +) + +@Stable +sealed interface InputOverlay { + data object None : InputOverlay + data class Reply(val name: UserName) : 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 index 8e73db573..11df71981 100644 --- 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 @@ -3,8 +3,6 @@ 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.Immutable -import androidx.compose.runtime.Stable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange import androidx.lifecycle.ViewModel @@ -36,6 +34,7 @@ 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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -127,16 +126,16 @@ class ChatInputViewModel( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - private val roomStateDisplayText: StateFlow = combine( + private val roomStateResources: StateFlow> = combine( chatSettingsDataStore.showChatModes, chatChannelProvider.activeChannel ) { showModes, channel -> showModes to channel }.flatMapLatest { (showModes, channel) -> - if (!showModes || channel == null) flowOf(null) - else channelRepository.getRoomStateFlow(channel).map { it.toDisplayText().ifEmpty { null } } + if (!showModes || channel == null) flowOf(emptyList()) + else channelRepository.getRoomStateFlow(channel).map { it.toDisplayTextResources() } }.distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val currentStreamInfo: StateFlow = combine( streamsSettingsDataStore.showStreamsInfo, @@ -147,15 +146,16 @@ class ChatInputViewModel( }.distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - private val helperText: StateFlow = combine( - roomStateDisplayText, + private val helperText: StateFlow = combine( + roomStateResources, currentStreamInfo ) { roomState, streamInfo -> - listOfNotNull(roomState, streamInfo) - .joinToString(separator = " - ") - .ifEmpty { null } + HelperText( + roomStateParts = roomState.toImmutableList(), + streamInfo = streamInfo, + ) }.distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HelperText()) private var _uiState: StateFlow? = null @@ -558,38 +558,3 @@ internal fun computeSuggestionReplacement(text: String, cursorPos: Int, suggesti ) } -@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: String? = null, - val isWhisperTabActive: Boolean = false, - val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, - val userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, -) - -@Stable -sealed interface InputOverlay { - data object None : InputOverlay - data class Reply(val name: UserName) : 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 -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c5dcf531..e10a83f17 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -294,8 +294,10 @@ Emote only Subscriber only Slow mode + Slow mode (%1$s) Unique chat (R9K) Follower only + Follower only (%1$s) Custom Any %1$ds From 40952402a156f324fd8e821aeab08bf8f25c6493 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 15:13:32 +0200 Subject: [PATCH 156/349] refactor: Extract state classes and sealed interfaces from ViewModels into separate files --- .../appearance/AppearanceSettingsState.kt | 20 +++++++ .../appearance/AppearanceSettingsViewModel.kt | 18 ------- .../preferences/chat/ChatSettingsState.kt | 52 +++++++++++++++++++ .../preferences/chat/ChatSettingsViewModel.kt | 50 ------------------ .../developer/DeveloperSettingsState.kt | 20 +++++++ .../developer/DeveloperSettingsViewModel.kt | 21 -------- .../preferences/tools/ToolsSettingsState.kt | 25 +++++++++ .../tools/ToolsSettingsViewModel.kt | 23 -------- .../ui/chat/message/MessageOptionsState.kt | 19 +++++++ .../chat/message/MessageOptionsViewModel.kt | 16 ------ .../dankchat/ui/main/MainScreenUiState.kt | 21 ++++++++ .../dankchat/ui/main/MainScreenViewModel.kt | 17 ------ .../ui/main/channel/ChannelTabUiState.kt | 24 +++++++++ .../ui/main/channel/ChannelTabViewModel.kt | 20 ------- .../dankchat/ui/main/dialog/DialogState.kt | 23 ++++++++ .../ui/main/dialog/DialogStateViewModel.kt | 18 ------- .../ui/main/sheet/SheetNavigationState.kt | 28 ++++++++++ .../ui/main/sheet/SheetNavigationViewModel.kt | 25 --------- 18 files changed, 232 insertions(+), 208 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabUiState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationState.kt 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..ade132d1e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -0,0 +1,20 @@ +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 +} + +@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 2237850f0..ce734dd58 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 @@ -1,6 +1,5 @@ package com.flxrs.dankchat.preferences.appearance -import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted @@ -42,20 +41,3 @@ class AppearanceSettingsViewModel( 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 AutoDisableInput(val value: Boolean) : AppearanceSettingsInteraction - data class ShowChangelogs(val value: Boolean) : AppearanceSettingsInteraction - data class ShowCharacterCounter(val value: Boolean) : AppearanceSettingsInteraction -} - -@Immutable -data class AppearanceSettingsUiState( - val settings: AppearanceSettings, -) 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..54db0a5a3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt @@ -0,0 +1,52 @@ +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 Suggestions(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 +} + +@Immutable +data class ChatSettingsState( + val suggestions: 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, +) 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 2fb9f43ec..ce5a2afb0 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 @@ -1,9 +1,7 @@ package com.flxrs.dankchat.preferences.chat -import androidx.compose.runtime.Immutable 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 @@ -70,54 +68,6 @@ class ChatSettingsViewModel( } } -sealed interface ChatSettingsEvent { - data object RestartRequired : ChatSettingsEvent -} - -sealed interface ChatSettingsInteraction { - data class Suggestions(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 -} - -@Immutable -data class ChatSettingsState( - val suggestions: 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, supibotSuggestions = supibotSuggestions, 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..410e8905c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt @@ -0,0 +1,20 @@ +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 ffe2f87a3..8728f2c1b 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 @@ -94,24 +94,3 @@ class DeveloperSettingsViewModel( } } } - -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/tools/ToolsSettingsState.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt new file mode 100644 index 000000000..4bf611f80 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt @@ -0,0 +1,25 @@ +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 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, +) 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..c6ecc5d89 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,7 +3,6 @@ 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 @@ -45,28 +44,6 @@ class ToolsSettingsViewModel( } } -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, 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..021703a47 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsState.kt @@ -0,0 +1,19 @@ +package com.flxrs.dankchat.ui.chat.message + +import com.flxrs.dankchat.data.UserName + +sealed interface MessageOptionsState { + data object Loading : MessageOptionsState + data object NotFound : MessageOptionsState + data class Found( + val messageId: String, + val rootThreadId: String, + val rootThreadName: UserName?, + 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 index 452e8135e..430f5fd1b 100644 --- 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 @@ -122,19 +122,3 @@ class MessageOptionsViewModel( ) } } - -sealed interface MessageOptionsState { - data object Loading : MessageOptionsState - data object NotFound : MessageOptionsState - data class Found( - val messageId: String, - val rootThreadId: String, - val rootThreadName: UserName?, - 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/main/MainScreenUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt new file mode 100644 index 000000000..5274426b5 --- /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 gestureInputHidden: Boolean = false, + val gestureToolbarHidden: Boolean = false, +) { + val effectiveShowInput: Boolean get() = showInput && !gestureInputHidden + 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 index feacb1816..c6cf5b539 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.ui.main -import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.UserName @@ -12,7 +11,6 @@ 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.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow @@ -180,18 +178,3 @@ class MainScreenViewModel( } private data class KeyboardHeightUpdate(val heightPx: Int, val isLandscape: Boolean) - -@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 gestureInputHidden: Boolean = false, - val gestureToolbarHidden: Boolean = false, -) { - val effectiveShowInput: Boolean get() = showInput && !gestureInputHidden - val effectiveShowAppBar: Boolean get() = !gestureToolbarHidden -} 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..3096cb92f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabUiState.kt @@ -0,0 +1,24 @@ +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, +) + +@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 index 91438402a..e5346662d 100644 --- 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 @@ -1,6 +1,5 @@ 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 @@ -10,8 +9,6 @@ import com.flxrs.dankchat.data.state.ChannelLoadingState import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.domain.ChannelDataCoordinator 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 @@ -82,20 +79,3 @@ class ChannelTabViewModel( chatNotificationRepository.clearMentionCounts() } } - -@Immutable -data class ChannelTabUiState( - val tabs: ImmutableList = persistentListOf(), - val selectedIndex: Int = 0, - val loading: Boolean = true, -) - -@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/dialog/DialogState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogState.kt new file mode 100644 index 000000000..f2c0d54c1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogState.kt @@ -0,0 +1,23 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.runtime.Immutable +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, +) 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 index b6ce15af8..03436e580 100644 --- 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 @@ -1,13 +1,11 @@ package com.flxrs.dankchat.ui.main.dialog -import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.preferences.DankChatPreferenceStore 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.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -130,19 +128,3 @@ class DialogStateViewModel( _state.value = _state.value.transform() } } - -@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, -) 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..ef597ca61 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationState.kt @@ -0,0 +1,28 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.UserName + +sealed interface FullScreenSheetState { + data object Closed : FullScreenSheetState + + @Immutable + data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState + data object Mention : FullScreenSheetState + data object Whisper : FullScreenSheetState + + @Immutable + data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState +} + +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 index 3ed715bcc..be5524f88 100644 --- 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 @@ -1,6 +1,5 @@ package com.flxrs.dankchat.ui.main.sheet -import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.UserName @@ -75,27 +74,3 @@ class SheetNavigationViewModel : ViewModel() { } } } - -sealed interface FullScreenSheetState { - data object Closed : FullScreenSheetState - - @Immutable - data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState - data object Mention : FullScreenSheetState - data object Whisper : FullScreenSheetState - - @Immutable - data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState -} - -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, -) From e142aedcd4ba42114feda740a54803d85330cfe5 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 15:32:46 +0200 Subject: [PATCH 157/349] refactor(compose): Add @Immutable annotations, use ImmutableList/Set in StateFlows, delete unused MessageClickEvent --- .../chat/userdisplay/UserDisplayEvent.kt | 2 ++ .../highlights/HighlightEvent.kt | 2 ++ .../notifications/ignores/IgnoreEvent.kt | 2 ++ .../dankchat/ui/changelog/ChangelogState.kt | 3 +++ .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 9 +++++--- .../dankchat/ui/chat/MessageClickEvent.kt | 9 -------- .../chat/history/MessageHistoryViewModel.kt | 22 +++++++++++++------ .../ui/chat/mention/MentionViewModel.kt | 14 ++++++++---- .../ui/chat/message/MessageOptionsState.kt | 2 ++ .../dankchat/ui/chat/suggestion/Suggestion.kt | 2 ++ .../dankchat/ui/chat/user/UserPopupState.kt | 2 ++ .../dankchat/ui/main/RepeatedSendData.kt | 3 +++ .../channel/ChannelManagementViewModel.kt | 9 ++++++-- .../ui/main/input/ChatInputViewModel.kt | 11 +++++----- .../ui/main/sheet/DebugInfoViewModel.kt | 9 ++++++-- .../ui/main/sheet/SheetNavigationState.kt | 6 ++--- 16 files changed, 71 insertions(+), 36 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/chat/MessageClickEvent.kt 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..6b6a69cf2 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,8 +1,10 @@ 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 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..c6049cd4f 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,8 +1,10 @@ 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 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..4014324d1 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,8 +1,10 @@ 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 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 index 11975a54c..c4c30d778 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt @@ -1,3 +1,6 @@ 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/chat/ChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt index 85dbc1311..61cac23ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -18,6 +18,9 @@ import com.flxrs.dankchat.preferences.chat.ChatSettings import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils import com.flxrs.dankchat.utils.extensions.isEven +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -75,7 +78,7 @@ class ChatViewModel( private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(Locale.getDefault()) - val chatUiStates: StateFlow> = combine( + val chatUiStates: StateFlow> = combine( chat, appearanceSettingsDataStore.settings, chatSettingsDataStore.settings @@ -133,9 +136,9 @@ class ChatViewModel( } } - result + result.toImmutableList() }.flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), persistentListOf()) fun manageAutomodMessage(heldMessageId: String, channel: UserName, allow: Boolean) { viewModelScope.launch { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/MessageClickEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/MessageClickEvent.kt deleted file mode 100644 index 135b3b448..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/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/ui/chat/history/MessageHistoryViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt index daed952b7..6baf2834e 100644 --- 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 @@ -23,6 +23,12 @@ 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 com.flxrs.dankchat.utils.extensions.isEven +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.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -93,28 +99,30 @@ class MessageHistoryViewModel( } }.flowOn(Dispatchers.Default) - private val users: StateFlow> = usersRepository.getUsersFlow(channel) + private val users: StateFlow> = usersRepository.getUsersFlow(channel) + .map { it.toImmutableSet() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) - private val badgeNames: StateFlow> = chatMessageRepository.getChat(channel) + private val badgeNames: StateFlow> = chatMessageRepository.getChat(channel) .map { items -> items.asSequence() .map { it.message } .filterIsInstance() .flatMap { it.badges } .mapNotNull { it.badgeTag?.substringBefore('/') } - .toSet() + .toImmutableSet() } .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) - val filterSuggestions: StateFlow> = combine( + val filterSuggestions: StateFlow> = combine( searchQuery, users, badgeNames, ) { query, userSet, badges -> - SearchFilterSuggestions.filter(query, users = userSet, badgeNames = badges) + SearchFilterSuggestions.filter(query, users = userSet, badgeNames = badges).toImmutableList() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) fun setInitialQuery(query: String) { if (query.isNotEmpty()) { 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 index 825591e17..eee4e62ab 100644 --- 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 @@ -11,6 +11,9 @@ 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.utils.extensions.isEven +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -18,6 +21,7 @@ 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.android.annotation.KoinViewModel @@ -48,10 +52,12 @@ class MentionViewModel( _currentTab.value = index } - val mentions: StateFlow> = chatNotificationRepository.mentions - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), emptyList()) - val whispers: StateFlow> = chatNotificationRepository.whispers - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), emptyList()) + 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()) val mentionsUiStates: Flow> = combine( mentions, 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 index 021703a47..fcb58b66c 100644 --- 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 @@ -1,7 +1,9 @@ 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 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 index ecad224c6..cc79899c4 100644 --- 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 @@ -1,10 +1,12 @@ 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() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt index 0cb4bbb5b..043f9677b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt @@ -1,9 +1,11 @@ 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 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 index e0f019368..caa731271 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt @@ -1,3 +1,6 @@ package com.flxrs.dankchat.ui.main +import androidx.compose.runtime.Immutable + +@Immutable 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/ui/main/channel/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt index 6919b803e..39bf6b8b8 100644 --- 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 @@ -13,8 +13,12 @@ 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.android.annotation.KoinViewModel @@ -32,9 +36,10 @@ class ChannelManagementViewModel( private val channelRepository: ChannelRepository, ) : ViewModel() { - val channels: StateFlow> = + val channels: StateFlow> = preferenceStore.getChannelsWithRenamesFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) init { // Set initial active channel if not already set 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 index 11df71981..053bd82b2 100644 --- 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 @@ -23,7 +23,6 @@ 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.UserLongClickBehavior import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore @@ -112,7 +111,7 @@ class ChatInputViewModel( private val debouncedTextAndCursor = textAndCursorFlow.debounce(SUGGESTION_DEBOUNCE_MS) // Get suggestions based on current text, cursor position, and active channel - private val suggestions: StateFlow> = combine( + private val suggestions: StateFlow> = combine( debouncedTextAndCursor, chatChannelProvider.activeChannel, chatSettingsDataStore.suggestions, @@ -124,9 +123,10 @@ class ChatInputViewModel( enabled -> suggestionProvider.getSuggestions(text, cursorPos, channel) else -> flowOf(emptyList()) } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + }.map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) - private val roomStateResources: StateFlow> = combine( + private val roomStateResources: StateFlow> = combine( chatSettingsDataStore.showChatModes, chatChannelProvider.activeChannel ) { showModes, channel -> @@ -135,7 +135,8 @@ class ChatInputViewModel( if (!showModes || channel == null) flowOf(emptyList()) else channelRepository.getRoomStateFlow(channel).map { it.toDisplayTextResources() } }.distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) private val currentStreamInfo: StateFlow = combine( streamsSettingsDataStore.showStreamsInfo, 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 index 067816b59..b9c9a0b0c 100644 --- 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 @@ -4,8 +4,12 @@ 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.android.annotation.KoinViewModel @@ -14,6 +18,7 @@ class DebugInfoViewModel( debugSectionRegistry: DebugSectionRegistry, ) : ViewModel() { - val sections: StateFlow> = debugSectionRegistry.allSections() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + 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/SheetNavigationState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationState.kt index ef597ca61..abdfa24ef 100644 --- 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 @@ -3,18 +3,16 @@ package com.flxrs.dankchat.ui.main.sheet import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.UserName +@Immutable sealed interface FullScreenSheetState { data object Closed : FullScreenSheetState - - @Immutable data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState data object Mention : FullScreenSheetState data object Whisper : FullScreenSheetState - - @Immutable data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState } +@Immutable sealed interface InputSheetState { data object Closed : InputSheetState data object EmoteMenu : InputSheetState From 14b3a4d33cb2ad7ffde391ddf26c12cedfe1a437 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 15:38:34 +0200 Subject: [PATCH 158/349] fix(ui): Animate input action icons when visibility changes on channel switch --- .../dankchat/ui/main/input/ChatInputLayout.kt | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) 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 index 12878c5f3..3537a74e2 100644 --- 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 @@ -382,6 +382,7 @@ fun ChatInputLayout( // Actions Row — uses BoxWithConstraints to hide actions that don't fit InputActionsRow( + inputActions = inputActions, effectiveActions = effectiveActions, isEmoteMenuOpen = isEmoteMenuOpen, enabled = enabled, @@ -795,6 +796,7 @@ private fun InputOverlayHeader( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun InputActionsRow( + inputActions: ImmutableList, effectiveActions: ImmutableList, isEmoteMenuOpen: Boolean, enabled: Boolean, @@ -831,6 +833,7 @@ private fun InputActionsRow( val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 val availableForActions = maxWidth - iconSize * fixedSlots val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) + val allActions = inputActions.take(maxVisibleActions).toImmutableList() val visibleActions = effectiveActions.take(maxVisibleActions).toImmutableList() onVisibleActionsChanged(visibleActions) @@ -869,6 +872,7 @@ private fun InputActionsRow( ) { Row(verticalAlignment = Alignment.CenterVertically) { EndAlignedActionGroup( + allActions = allActions, visibleActions = visibleActions, iconSize = iconSize, showQuickActions = showQuickActions, @@ -901,6 +905,7 @@ private fun InputActionsRow( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EndAlignedActionGroup( + allActions: ImmutableList, visibleActions: ImmutableList, iconSize: Dp, showQuickActions: Boolean, @@ -967,23 +972,29 @@ private fun EndAlignedActionGroup( } } - // Configurable action icons - for (action in visibleActions) { - 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), - ) + // Configurable action icons with animated visibility + 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) From c086ff70d55922e31d9ec62578fcb93b190102b0 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 17:42:43 +0200 Subject: [PATCH 159/349] test(domain): Add ChannelDataCoordinator and ChannelDataLoader tests, remove dead code, fix stale comment --- .../data/auth/AuthStateCoordinator.kt | 2 +- .../data/auth/StartupValidationHolder.kt | 4 - .../dankchat/domain/ChannelDataCoordinator.kt | 1 - .../dankchat/domain/ChannelDataLoader.kt | 3 - .../flxrs/dankchat/domain/GlobalDataLoader.kt | 7 - .../domain/ChannelDataCoordinatorTest.kt | 267 ++++++++++++++++++ .../dankchat/domain/ChannelDataLoaderTest.kt | 206 ++++++++++++++ 7 files changed, 474 insertions(+), 16 deletions(-) create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt 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 index c5c55daf0..71b91a403 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -54,7 +54,7 @@ class AuthStateCoordinator( 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 DankChatViewModel.init). + // drop(1) skips initial emission (startup connection handled by ConnectionCoordinator). scope.launch { authDataStore.settings .distinctUntilChangedBy { it.isLoggedIn to it.oAuthKey } 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 index 9873444ec..f047589e8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt @@ -33,10 +33,6 @@ class StartupValidationHolder { _state.value = StartupValidation.Validated } - suspend fun awaitValidated() { - _state.first { it is StartupValidation.Validated } - } - suspend fun awaitResolved() { _state.first { it !is StartupValidation.Pending } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 6fb776d68..8f149f44c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -260,5 +260,4 @@ class ChannelDataCoordinator( } } } - } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index 304b757da..cecf1d711 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -120,7 +120,4 @@ class ChannelDataLoader( } } - suspend fun loadRecentMessages(channel: UserName) { - chatRepository.loadRecentMessagesIfEnabled(channel) - } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index a2863bbcd..4298d36e4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -50,11 +50,4 @@ class GlobalDataLoader( suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result { return dataRepository.loadUserEmotes(userId, onFirstPageLoaded) } - - suspend fun loadUserStateEmotes( - globalEmoteSets: List, - followerEmoteSets: Map> - ) { - dataRepository.loadUserStateEmotes(globalEmoteSets, followerEmoteSets) - } } 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..bbedcd7de --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt @@ -0,0 +1,267 @@ +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..cf2696340 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt @@ -0,0 +1,206 @@ +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) + } +} From 7a04b338724e0a6d0a832971a53a0890fcdd9dad Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 18:17:14 +0200 Subject: [PATCH 160/349] build: Add ktfmt and detekt with compose-rules, update CI and editorconfig --- .editorconfig | 15 ++++--- .github/workflows/android.yml | 12 +++-- app/build.gradle.kts | 83 ++++++++++++++++------------------- app/config/detekt.yml | 57 ++++++++++++++++++++++++ build.gradle.kts | 2 + gradle/libs.versions.toml | 8 ++++ 6 files changed, 124 insertions(+), 53 deletions(-) create mode 100644 app/config/detekt.yml diff --git a/.editorconfig b/.editorconfig index 2c00134c9..2f7234da3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -40,14 +40,17 @@ ij_xml_text_wrap = normal ij_xml_use_custom_settings = true [{*.kt,*.kts,*.main.kts}] -ij_kotlin_align_in_columns_case_branch = true +# ktfmt kotlinlang-style compatible settings +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 +62,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 +72,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 +86,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 +125,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..0782738cf 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -33,18 +33,24 @@ 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 + - name: ktfmt check + run: bash ./gradlew :app:ktfmtCheck + + - name: Detekt + run: bash ./gradlew :app:detekt + + - name: Android Lint run: bash ./gradlew lintVitalRelease build: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b48b9386d..c8001b87b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,9 @@ import com.android.build.api.artifact.ArtifactTransformationRequest import com.android.build.api.artifact.SingleArtifact import com.android.build.gradle.internal.PropertiesValueSource -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.StringReader import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) @@ -13,6 +13,8 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.about.libraries.android) alias(libs.plugins.android.junit5) + alias(libs.plugins.ktfmt) + alias(libs.plugins.detekt) } android { @@ -27,9 +29,7 @@ android { versionName = "3.11.11" } - androidResources { - generateLocaleConfig = true - } + androidResources { generateLocaleConfig = true } val localProperties = gradleLocalProperties(rootDir, providers) signingConfigs { @@ -53,9 +53,7 @@ android { } } - testOptions { - unitTests.isReturnDefaultValues = true - } + testOptions { unitTests.isReturnDefaultValues = true } buildTypes { getByName("release") { @@ -80,15 +78,9 @@ android { } 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 - } + 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 { @@ -111,9 +103,7 @@ ksp { arg("KOIN_USE_COMPOSE_VIEWMODEL", "true") } -tasks.withType { - useJUnitPlatform() -} +tasks.withType { useJUnitPlatform() } kotlin { jvmToolchain(jdkVersion = 21) @@ -136,10 +126,13 @@ kotlin { } 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,7 +141,7 @@ 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) @@ -167,7 +160,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) @@ -181,11 +174,11 @@ dependencies { implementation(libs.compose.unstyled) implementation(libs.compose.material3.adaptive) -// Material + // Material implementation(libs.android.material) implementation(libs.android.flexbox) -// Dependency injection + // Dependency injection implementation(platform(libs.koin.bom)) implementation(libs.koin.core) implementation(libs.koin.android) @@ -195,14 +188,14 @@ dependencies { 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) @@ -212,14 +205,14 @@ dependencies { implementation(libs.ktor.client.websockets) implementation(libs.ktor.serialization.kotlinx.json) -// Other + // Other implementation(libs.colorpicker.android) implementation(libs.process.phoenix) implementation(libs.autolinktext) implementation(libs.aboutlibraries.compose.m3) implementation(libs.reorderable) -// Test + // Test testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.mockk) @@ -239,32 +232,34 @@ junitPlatform { } } +ktfmt { + kotlinLangStyle() + maxWidth.set(200) +} + +detekt { + buildUponDefaultConfig = true + config.setFrom("$projectDir/config/detekt.yml") + parallel = true +} + fun gradleLocalProperties(projectRootDir: File, providers: ProviderFactory): Properties { val properties = Properties() - val propertiesContent = - providers.of(PropertiesValueSource::class.java) { - parameters.projectRoot.set(projectRootDir) - }.get() + val propertiesContent = providers.of(PropertiesValueSource::class.java) { parameters.projectRoot.set(projectRootDir) }.get() - StringReader(propertiesContent).use { reader -> - properties.load(reader) - } + StringReader(propertiesContent).use { reader -> properties.load(reader) } return properties } abstract class RenameApkTask : DefaultTask() { - @get:InputDirectory - abstract val inputDirs: DirectoryProperty + @get:InputDirectory abstract val inputDirs: DirectoryProperty - @get:OutputDirectory - abstract val outputDirs: DirectoryProperty + @get:OutputDirectory abstract val outputDirs: DirectoryProperty - @get:Input - abstract val apkName: Property + @get:Input abstract val apkName: Property - @get:Internal - lateinit var transformationRequest: ArtifactTransformationRequest + @get:Internal lateinit var transformationRequest: ArtifactTransformationRequest @TaskAction fun taskAction() { diff --git a/app/config/detekt.yml b/app/config/detekt.yml new file mode 100644 index 000000000..740a9ce05 --- /dev/null +++ b/app/config/detekt.yml @@ -0,0 +1,57 @@ +# 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: + threshold: 100 + LongParameterList: + functionThreshold: 14 + constructorThreshold: 18 + TooManyFunctions: + thresholdInFiles: 30 + thresholdInClasses: 30 + CyclomaticComplexMethod: + threshold: 25 + ComplexCondition: + threshold: 5 + +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: 200 + excludeCommentStatements: true + ReturnCount: + max: 8 + ForbiddenComment: + active: false + DestructuringDeclarationWithTooManyEntries: + maxDestructuringEntries: 4 + LoopWithTooManyJumpStatements: + maxJumpCount: 3 + +exceptions: + TooGenericExceptionCaught: + active: false + +Compose: + ModifierMissing: + active: false + CompositionLocalAllowlist: + allowedCompositionLocals: + - LocalEmoteAnimationCoordinator + - LocalAdaptiveColors + - LocalContentAlpha diff --git a/build.gradle.kts b/build.gradle.kts index bde020686..013144759 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,4 +12,6 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.about.libraries.android) apply false alias(libs.plugins.android.junit5) apply false + alias(libs.plugins.ktfmt) apply false + alias(libs.plugins.detekt) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f76d48cf..2fd9f4ddc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,10 @@ processPhoenix = "3.0.0" colorPicker = "3.1.0" reorderable = "2.4.3" +ktfmt = "0.26.0" +detekt = "1.23.8" +composeRules = "0.4.23" + junit = "6.0.3" androidJunit5 = "2.0.1" mockk = "1.14.9" @@ -133,6 +137,8 @@ process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "p 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" } @@ -150,4 +156,6 @@ nav-safeargs-kotlin = { id = "androidx.navigation.safeargs.kotlin", version.ref ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } about-libraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "about-libraries" } android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJunit5" } +ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } androidx-room = { id = "androidx.room", version.ref = "androidxRoom" } #TODO use me when working From c2339a9c9d98fad4a7dcd98bfb57b03799ec7eb5 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 18:56:59 +0200 Subject: [PATCH 161/349] fix: Resolve all detekt and compose-rules violations, remove dead code, rename parameters to imperative style --- .editorconfig | 2 +- app/build.gradle.kts | 2 +- app/config/detekt.yml | 16 +++-- .../com/flxrs/dankchat/data/DisplayName.kt | 2 +- .../kotlin/com/flxrs/dankchat/data/UserId.kt | 2 +- .../flxrs/dankchat/data/api/auth/AuthApi.kt | 2 +- .../data/api/auth/dto/ValidateErrorDto.kt | 2 +- .../dankchat/data/api/badges/BadgesApi.kt | 2 +- .../data/api/badges/dto/TwitchBadgeDto.kt | 2 +- .../data/api/badges/dto/TwitchBadgeSetDto.kt | 2 +- .../data/api/badges/dto/TwitchBadgeSetsDto.kt | 2 +- .../flxrs/dankchat/data/api/bttv/BTTVApi.kt | 2 +- .../data/api/bttv/dto/BTTVChannelDto.kt | 2 +- .../data/api/bttv/dto/BTTVGlobalEmoteDto.kt | 2 +- .../dankchat/data/api/dankchat/DankChatApi.kt | 2 +- .../data/api/dankchat/dto/DankChatBadgeDto.kt | 2 +- .../data/api/dankchat/dto/DankChatEmoteDto.kt | 2 +- .../api/dankchat/dto/DankChatEmoteSetDto.kt | 2 +- .../com/flxrs/dankchat/data/api/ffz/FFZApi.kt | 2 +- .../data/api/ffz/dto/FFZChannelDto.kt | 2 +- .../data/api/ffz/dto/FFZEmoteSetDto.kt | 2 +- .../dankchat/data/api/ffz/dto/FFZGlobalDto.kt | 2 +- .../dankchat/data/api/ffz/dto/FFZRoomDto.kt | 2 +- .../dankchat/data/api/helix/HelixApiClient.kt | 2 +- .../api/recentmessages/RecentMessagesApi.kt | 2 +- .../RecentMessagesApiException.kt | 2 +- .../recentmessages/dto/RecentMessagesDto.kt | 2 +- .../dankchat/data/api/supibot/SupibotApi.kt | 2 +- .../data/api/supibot/dto/SupibotChannelDto.kt | 2 +- .../api/supibot/dto/SupibotChannelsDto.kt | 2 +- .../data/api/supibot/dto/SupibotCommandDto.kt | 2 +- .../api/supibot/dto/SupibotUserAliasDto.kt | 2 +- .../api/supibot/dto/SupibotUserAliasesDto.kt | 2 +- .../dankchat/data/api/upload/dto/UploadDto.kt | 2 +- .../database/converter/InstantConverter.kt | 2 +- .../data/database/dao/BlacklistedUserDao.kt | 2 +- .../data/database/dao/EmoteUsageDao.kt | 2 +- .../data/database/dao/MessageHighlightDao.kt | 2 +- .../data/database/dao/MessageIgnoreDao.kt | 2 +- .../data/database/dao/RecentUploadsDao.kt | 2 +- .../data/database/dao/UserHighlightDao.kt | 2 +- .../data/database/dao/UserIgnoreDao.kt | 2 +- .../data/database/entity/UploadEntity.kt | 2 +- .../dankchat/data/repo/IgnoresRepository.kt | 1 + .../dankchat/data/repo/RepliesRepository.kt | 3 - .../data/repo/chat/ChatEventProcessor.kt | 4 +- .../data/repo/chat/RecentMessagesHandler.kt | 1 + .../data/repo/command/CommandRepository.kt | 1 + .../data/repo/emote/EmoteRepository.kt | 1 + .../dankchat/data/repo/stream/StreamData.kt | 2 +- .../data/twitch/chat/ChatConnection.kt | 4 +- .../data/twitch/emote/GenericEmote.kt | 2 +- .../data/twitch/message/SystemMessage.kt | 2 +- .../data/twitch/pubsub/PubSubConnection.kt | 5 +- .../dankchat/domain/ChannelDataLoader.kt | 2 +- .../dankchat/preferences/about/AboutScreen.kt | 4 +- .../appearance/AppearanceSettingsScreen.kt | 12 ++-- .../preferences/chat/ChatSettingsScreen.kt | 12 ++-- .../chat/commands/CommandsScreen.kt | 14 ++-- .../chat/userdisplay/UserDisplayScreen.kt | 2 +- .../components/CheckboxWithText.kt | 1 + .../components/PreferenceCategory.kt | 6 +- .../preferences/components/PreferenceItem.kt | 7 +- .../components/PreferenceListDialog.kt | 12 ++-- .../components/PreferenceMultiListDialog.kt | 8 +-- .../developer/DeveloperSettingsScreen.kt | 38 +++++----- .../developer/DeveloperSettingsViewModel.kt | 2 - .../NotificationsSettingsScreen.kt | 2 +- .../highlights/HighlightsScreen.kt | 70 +++++++++---------- .../notifications/ignores/IgnoresScreen.kt | 40 +++++------ .../overview/OverviewSettingsScreen.kt | 33 ++++----- .../preferences/overview/SecretDankerMode.kt | 1 + .../stream/StreamsSettingsScreen.kt | 8 +-- .../preferences/tools/ToolsSettingsScreen.kt | 4 +- .../tools/tts/TTSUserIgnoreListScreen.kt | 8 +-- .../dankchat/ui/changelog/ChangelogScreen.kt | 6 +- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 10 +-- .../dankchat/ui/chat/ChatMessageMapper.kt | 6 -- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 12 ++-- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 1 - .../ui/chat/EmoteAnimationCoordinator.kt | 2 +- .../flxrs/dankchat/ui/chat/EmoteScaling.kt | 8 --- .../flxrs/dankchat/ui/chat/StackedEmote.kt | 4 +- .../chat/history/MessageHistoryViewModel.kt | 1 - .../ui/chat/mention/MentionComposable.kt | 4 +- .../ui/chat/mention/MentionViewModel.kt | 2 - .../dankchat/ui/chat/messages/PrivMessage.kt | 9 +-- .../ui/chat/messages/WhisperAndRedemption.kt | 4 +- .../ui/chat/replies/RepliesComposable.kt | 8 +-- .../dankchat/ui/chat/replies/RepliesState.kt | 2 +- .../ui/chat/replies/RepliesViewModel.kt | 1 - .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 11 ++- .../flxrs/dankchat/ui/main/MainActivity.kt | 17 +++-- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 2 +- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 24 +++---- .../dankchat/ui/main/RepeatedSendData.kt | 2 +- .../dankchat/ui/main/channel/ChannelTabRow.kt | 4 +- .../ui/main/dialog/MainScreenDialogs.kt | 4 +- .../ui/main/dialog/ManageChannelsDialog.kt | 13 ++-- .../ui/main/dialog/MessageOptionsDialog.kt | 2 - .../ui/main/dialog/ModActionsDialog.kt | 2 +- .../dankchat/ui/main/input/ChatBottomBar.kt | 14 ++-- .../dankchat/ui/main/input/ChatInputLayout.kt | 62 ++++++++-------- .../ui/main/input/ChatInputViewModel.kt | 56 +++++++-------- .../ui/main/sheet/FullScreenSheetOverlay.kt | 4 +- .../dankchat/ui/main/sheet/MentionSheet.kt | 3 +- .../ui/main/sheet/MessageHistorySheet.kt | 2 +- .../dankchat/ui/main/sheet/RepliesSheet.kt | 4 +- .../dankchat/ui/main/stream/StreamView.kt | 1 + .../ui/onboarding/OnboardingScreen.kt | 8 +-- .../dankchat/ui/share/ShareUploadActivity.kt | 2 +- .../utils/compose/ConfirmationBottomSheet.kt | 4 +- .../dankchat/utils/compose/ContentAlpha.kt | 8 +-- .../dankchat/utils/compose/InfoBottomSheet.kt | 4 +- .../utils/compose/InputBottomSheet.kt | 4 +- .../utils/compose/RoundedCornerPadding.kt | 1 + .../utils/extensions/GifExtensions.kt | 2 +- .../utils/extensions/StringExtensions.kt | 1 + .../flxrs/dankchat/data/irc/IrcMessageTest.kt | 1 + 119 files changed, 370 insertions(+), 384 deletions(-) diff --git a/.editorconfig b/.editorconfig index 2f7234da3..45d63e8e1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,7 +4,7 @@ end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true -max_line_length = 200 +max_line_length = 250 tab_width = 4 ij_continuation_indent_size = 8 ij_formatter_off_tag = @formatter:off diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8001b87b..d6b96d9c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -234,7 +234,7 @@ junitPlatform { ktfmt { kotlinLangStyle() - maxWidth.set(200) + maxWidth.set(250) } detekt { diff --git a/app/config/detekt.yml b/app/config/detekt.yml index 740a9ce05..7116e2243 100644 --- a/app/config/detekt.yml +++ b/app/config/detekt.yml @@ -4,15 +4,15 @@ complexity: LongMethod: - threshold: 100 + active: false LongParameterList: - functionThreshold: 14 - constructorThreshold: 18 + active: false TooManyFunctions: - thresholdInFiles: 30 - thresholdInClasses: 30 + active: false CyclomaticComplexMethod: - threshold: 25 + active: false + NestedBlockDepth: + active: false ComplexCondition: threshold: 5 @@ -32,7 +32,7 @@ style: MagicNumber: active: false MaxLineLength: - maxLineLength: 200 + maxLineLength: 250 excludeCommentStatements: true ReturnCount: max: 8 @@ -50,6 +50,8 @@ exceptions: Compose: ModifierMissing: active: false + LambdaParameterInRestartableEffect: + active: false CompositionLocalAllowlist: allowedCompositionLocals: - LocalEmoteAnimationCoordinator 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..8c5d428a2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt @@ -12,4 +12,4 @@ value class DisplayName(val value: String) : Parcelable { } 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..d0da9e237 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt @@ -14,4 +14,4 @@ value class UserId(val value: String) : Parcelable { 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 +} 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 53256c193..bf3fc804e 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 @@ -19,4 +19,4 @@ class AuthApi(private val ktorClient: HttpClient) { append("token", token) } ) -} \ No newline at end of file +} 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..e335672cd 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 @@ -8,4 +8,4 @@ import kotlinx.serialization.Serializable data class ValidateErrorDto( val status: Int, val message: String -) \ No newline at end of file +) 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..ad885601e 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 @@ -9,4 +9,4 @@ 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/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..8276097d6 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,4 @@ 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..61d685bb0 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,4 @@ 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..5cd38d9c5 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 @@ -9,4 +9,4 @@ 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/dto/BTTVChannelDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVChannelDto.kt index e07dc375d..65ef15796 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 @@ -11,4 +11,4 @@ data class BTTVChannelDto( @SerialName(value = "bots") val bots: List, @SerialName(value = "channelEmotes") val emotes: List, @SerialName(value = "sharedEmotes") val sharedEmotes: List -) \ No newline at end of file +) 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..634899878 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 @@ -11,4 +11,4 @@ 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/dto/DankChatBadgeDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatBadgeDto.kt index 2cfedf09f..ce727ae77 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 @@ -11,4 +11,4 @@ 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 +) 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 index 41c42c73e..890d4120e 100644 --- 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 @@ -11,4 +11,4 @@ data class DankChatEmoteDto( @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 index 611bfafa1..370a4ac8e 100644 --- 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 @@ -14,4 +14,4 @@ data class DankChatEmoteSetDto( @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/ffz/FFZApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApi.kt index addd35501..8b929ab54 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 @@ -9,4 +9,4 @@ 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/dto/FFZChannelDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZChannelDto.kt index 942078a4e..91ee6a3e4 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 @@ -9,4 +9,4 @@ import kotlinx.serialization.Serializable data class FFZChannelDto( @SerialName(value = "room") val room: FFZRoomDto, @SerialName(value = "sets") val sets: Map -) \ No newline at end of file +) 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..33308395f 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,4 @@ 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..1a229f981 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 @@ -9,4 +9,4 @@ import kotlinx.serialization.Serializable data class FFZGlobalDto( @SerialName(value = "default_sets") val defaultSets: List, @SerialName(value = "sets") val sets: Map -) \ No newline at end of file +) 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/HelixApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiClient.kt index 2507603b3..9006f2830 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 @@ -325,6 +325,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()) { @@ -405,7 +406,6 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } 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" 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..a14095d0e 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 @@ -10,4 +10,4 @@ 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/RecentMessagesApiException.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiException.kt index 954cce107..e100f8495 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 @@ -16,4 +16,4 @@ enum class RecentMessagesError { ChannelNotJoined, ChannelIgnored, Unknown -} \ No newline at end of file +} 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/supibot/SupibotApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApi.kt index 74dd002f2..694f9ede0 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 @@ -14,4 +14,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/dto/SupibotChannelDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelDto.kt index 93be8e1a1..21295b35f 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 @@ -10,4 +10,4 @@ import kotlinx.serialization.Serializable 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..d9ea08412 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,4 @@ 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..e1c32a22e 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,4 @@ 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/SupibotUserAliasDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasDto.kt index 0a14ceb7b..0deb78577 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,4 @@ 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..9cd5106db 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,4 @@ 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/dto/UploadDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/dto/UploadDto.kt index 923d11a0a..4457544df 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 @@ -6,4 +6,4 @@ data class UploadDto( val imageLink: String, val deleteLink: String?, val timestamp: Instant -) \ No newline at end of file +) 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..602f5d537 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 @@ -10,4 +10,4 @@ object InstantConverter { @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/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 9c777d7d9..87ada0182 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 @@ -24,4 +24,4 @@ interface EmoteUsageDao { companion object { 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..30255f9c9 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 @@ -30,4 +30,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..568740815 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 @@ -30,4 +30,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..2f608f50d 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 @@ -21,4 +21,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/UserHighlightDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserHighlightDao.kt index fc4a3075c..d6fee0efd 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 @@ -30,4 +30,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..fdf0f2551 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 @@ -27,4 +27,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/UploadEntity.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/UploadEntity.kt index b907a478c..3f4b0fe3b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/UploadEntity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/UploadEntity.kt @@ -16,4 +16,4 @@ data class UploadEntity( @ColumnInfo(name = "delete_link") val deleteLink: String? -) \ No newline at end of file +) 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 723cd4559..9fa20dc51 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 @@ -216,6 +216,7 @@ class IgnoresRepository( ) } + @Suppress("ReturnCount") private fun PrivMessage.applyIgnores(): PrivMessage? { val messageIgnores = validMessageIgnores.value 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 3e5069cff..de9ddaba1 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 @@ -180,8 +180,6 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { } companion object { - private val TAG = RepliesRepository::class.java.simpleName - private const val PARENT_MESSAGE_ID_TAG = "reply-parent-msg-id" private const val PARENT_MESSAGE_LOGIN_TAG = "reply-parent-user-login" private const val PARENT_MESSAGE_DISPLAY_TAG = "reply-parent-display-name" @@ -190,6 +188,5 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { 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" - private const val THREAD_ROOT_USER_ID_TAG = "reply-thread-parent-user-id" } } 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 index cbd01a549..8fb612496 100644 --- 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 @@ -103,7 +103,7 @@ class ChatEventProcessor( private suspend fun collectReadConnectionEvents() { chatConnector.readEvents.collect { event -> when (event) { - is ChatEvent.Connected -> handleConnected(event.channel, event.isAnonymous) + 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) @@ -340,7 +340,7 @@ class ChatEventProcessor( postSystemMessageAndReconnect(state.toSystemMessageType()) } - private fun handleConnected(channel: UserName, isAnonymous: Boolean) { + private fun handleConnected(isAnonymous: Boolean) { val state = when { isAnonymous -> ConnectionState.CONNECTED_NOT_LOGGED_IN else -> ConnectionState.CONNECTED 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 index eeb27b9a1..b616ee291 100644 --- 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 @@ -43,6 +43,7 @@ class RecentMessagesHandler( val userSuggestions: List>, ) + @Suppress("LoopWithTooManyJumpStatements") suspend fun load(channel: UserName, isReconnect: Boolean = false): Result = withContext(Dispatchers.IO) { if (!isReconnect && channel in loadedChannels) { return@withContext Result(emptyList(), emptyList()) 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 fa72f25a4..ce8a9441e 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 @@ -81,6 +81,7 @@ class CommandRepository( fun getSupibotCommands(channel: UserName): StateFlow> = supibotCommands.getOrPut(channel) { MutableStateFlow(emptyList()) } + @Suppress("ReturnCount") suspend fun checkForCommands(message: String, channel: UserName, roomState: RoomState, userState: UserState, skipSuspendingCommands: Boolean = false): CommandResult { if (!authDataStore.isLoggedIn) { return CommandResult.NotFound 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 1ff0cf735..8596014b0 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 @@ -63,6 +63,7 @@ import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +@Suppress("LargeClass") @Single class EmoteRepository( private val dankChatApiClient: DankChatApiClient, 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 index ac94b5aec..4d71096dc 100644 --- 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 @@ -8,4 +8,4 @@ data class StreamData( val viewerCount: Int = 0, val startedAt: String = "", val category: String? = null, -) \ No newline at end of file +) 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 8b70c6b2f..dbb716d77 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 @@ -240,9 +240,9 @@ class ChatConnection( } Log.i(TAG, "[$chatConnectionType] reconnecting after server request") + } catch (t: CancellationException) { + throw t } catch (t: Throwable) { - if (t is CancellationException) throw t - Log.e(TAG, "[$chatConnectionType] connection failed: $t") Log.e(TAG, "[$chatConnectionType] attempting to reconnect #$retryCount..") _connected.value = false 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..5193886e8 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 @@ -16,4 +16,4 @@ data class GenericEmote( override fun compareTo(other: GenericEmote): Int { return code.compareTo(other.code) } -} \ No newline at end of file +} 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..61a7a5cac 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/pubsub/PubSubConnection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubConnection.kt index b9064d96c..5cd352242 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 @@ -146,9 +146,9 @@ class PubSubConnection( } Log.i(TAG, "[PubSub $tag] reconnecting after server request") + } catch (t: CancellationException) { + throw t } catch (t: Throwable) { - if (t is CancellationException) throw t - Log.e(TAG, "[PubSub $tag] connection failed: $t") Log.e(TAG, "[PubSub $tag] attempting to reconnect #$retryCount..") session = null @@ -255,6 +255,7 @@ class PubSubConnection( /** * 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 } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index cecf1d711..9ffb82004 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -67,7 +67,7 @@ class ChannelDataLoader( failures.isEmpty() -> ChannelLoadingState.Loaded else -> ChannelLoadingState.Failed(failures) } - } catch (e: Exception) { + } catch (_: Exception) { ChannelLoadingState.Failed(emptyList()) } } 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 index 4b70b6042..1a6aed724 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -46,7 +46,7 @@ import sh.calvin.autolinktext.annotateString @Composable fun AboutScreen( - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -60,7 +60,7 @@ fun AboutScreen( title = { Text(stringResource(R.string.open_source_licenses)) }, navigationIcon = { IconButton( - onClick = onBackPressed, + onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) } 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 index c884f5fcb..32818d8a2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -61,7 +61,7 @@ import kotlin.math.roundToInt @Composable fun AppearanceSettingsScreen( - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val viewModel = koinViewModel() val uiState = viewModel.settings.collectAsStateWithLifecycle().value @@ -70,7 +70,7 @@ fun AppearanceSettingsScreen( settings = uiState.settings, onInteraction = { viewModel.onInteraction(it) }, onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, - onBackPressed = onBackPressed + onBack = onBack ) } @@ -79,7 +79,7 @@ private fun AppearanceSettingsContent( settings: AppearanceSettings, onInteraction: (AppearanceSettingsInteraction) -> Unit, onSuspendingInteraction: suspend (AppearanceSettingsInteraction) -> Unit, - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -91,7 +91,7 @@ private fun AppearanceSettingsContent( title = { Text(stringResource(R.string.preference_appearance_header)) }, navigationIcon = { IconButton( - onClick = onBackPressed, + onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) } @@ -170,7 +170,7 @@ private fun DisplayCategory( value = value, range = 10f..40f, onDrag = { value = it }, - onDragFinished = { onInteraction(FontSize(value.roundToInt())) }, + onDragFinish = { onInteraction(FontSize(value.roundToInt())) }, summary = summary, ) @@ -212,7 +212,7 @@ private fun ThemeCategory( values = themeState.values, entries = themeState.entries, selected = themeState.preference, - onChanged = { + onChange ={ scope.launch { activity ?: return@launch onInteraction(Theme(it)) 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 7611ade03..461d7dcca 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 @@ -215,7 +215,7 @@ 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(), ) @@ -235,7 +235,7 @@ private fun GeneralCategory( values = UserLongClickBehavior.entries.toImmutableList(), entries = longClickEntries, selected = userLongClickBehavior, - onChanged = { onInteraction(ChatSettingsInteraction.UserLongClick(it)) }, + onChange ={ onInteraction(ChatSettingsInteraction.UserLongClick(it)) }, ) PreferenceItem( @@ -262,7 +262,7 @@ private fun GeneralCategory( values = timestampFormats, entries = timestampFormats, selected = timestampFormat, - onChanged = { onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, + onChange ={ onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, ) val entries = stringArrayResource(R.array.badges_entries) @@ -274,14 +274,14 @@ private fun GeneralCategory( initialSelected = visibleBadges, values = VisibleBadges.entries.toImmutableList(), entries = entries, - onChanged = { onInteraction(ChatSettingsInteraction.Badges(it)) }, + 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)) }, ) } } @@ -321,7 +321,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/commands/CommandsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsScreen.kt index a73058f21..98b095822 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 @@ -142,12 +142,12 @@ private fun CustomCommandsScreen( .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 -> CustomCommandItem( trigger = command.trigger, command = command.command, - onTriggerChanged = { commands[idx] = command.copy(trigger = it) }, - onCommandChanged = { commands[idx] = command.copy(command = it) }, + onTriggerChange = { commands[idx] = command.copy(trigger = it) }, + onCommandChange = { commands[idx] = command.copy(command = it) }, onRemove = { focusManager.clearFocus() val removed = commands.removeAt(idx) @@ -184,8 +184,8 @@ private fun CustomCommandsScreen( private fun CustomCommandItem( trigger: String, command: String, - onTriggerChanged: (String) -> Unit, - onCommandChanged: (String) -> Unit, + onTriggerChange: (String) -> Unit, + onCommandChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -200,7 +200,7 @@ private fun CustomCommandItem( OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = trigger, - onValueChange = onTriggerChanged, + onValueChange = onTriggerChange, label = { Text(stringResource(R.string.command_trigger_hint)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), maxLines = 1, @@ -209,7 +209,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, 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 290d79b44..695451268 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 @@ -186,7 +186,7 @@ private fun UserDisplayScreen( .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 }, 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..2eff03e62 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, 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 40a8d847c..eacd1a265 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 @@ -55,9 +55,10 @@ fun PreferenceCategoryTitle(text: String, modifier: Modifier = Modifier) { ) } +@Suppress("UnusedPrivateMember") @Composable @PreviewLightDark -fun PreferenceCategoryPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { +private fun PreferenceCategoryPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { DankChatTheme { Surface { PreferenceCategoryWithSummary( @@ -68,9 +69,10 @@ fun PreferenceCategoryPreview(@PreviewParameter(provider = LoremIpsum::class) lo } } +@Suppress("UnusedPrivateMember") @Composable @PreviewLightDark -fun PreferenceCategoryWithItemsPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { +private fun PreferenceCategoryWithItemsPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { DankChatTheme { Surface { PreferenceCategory( 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 03db974f7..68649dfb5 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 @@ -101,7 +101,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,7 +121,7 @@ fun SliderPreferenceItem( Slider( value = value, onValueChange = onDrag, - onValueChangeFinished = onDragFinished, + onValueChangeFinished = onDragFinish, valueRange = range, steps = steps, modifier = Modifier @@ -277,9 +277,10 @@ private fun RowScope.PreferenceItemContent( } } +@Suppress("UnusedPrivateMember") @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 ed50b72ce..581433b79 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, @@ -48,16 +48,16 @@ fun PreferenceListDialog( 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, + selected = selected == value, onClick = { - onChanged(it) + onChange(value) scope.launch { sheetState.hide() dismiss() @@ -69,9 +69,9 @@ fun PreferenceListDialog( .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 780fca447..903c0359d 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 @@ -32,7 +32,7 @@ fun PreferenceMultiListDialog( values: ImmutableList, initialSelected: ImmutableList, entries: ImmutableList, - onChanged: (List) -> Unit, + onChange: (List) -> Unit, isEnabled: Boolean = true, summary: String? = null, icon: ImageVector? = null, @@ -48,12 +48,12 @@ 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( @@ -74,7 +74,7 @@ fun PreferenceMultiListDialog( interactionSource = interactionSource, ) Text( - text = it, + text = entry, modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyLarge, lineHeight = 18.sp, 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 index 1d7ed9b2f..c5092972e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -85,7 +85,7 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun DeveloperSettingsScreen( - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value @@ -120,7 +120,7 @@ fun DeveloperSettingsScreen( settings = settings, snackbarHostState = snackbarHostState, onInteraction = { viewModel.onInteraction(it) }, - onBackPressed = onBackPressed, + onBack = onBack, ) } @@ -130,7 +130,7 @@ private fun DeveloperSettingsContent( settings: DeveloperSettings, snackbarHostState: SnackbarHostState, onInteraction: (DeveloperSettingsInteraction) -> Unit, - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -143,7 +143,7 @@ private fun DeveloperSettingsContent( title = { Text(stringResource(R.string.preference_developer_header)) }, navigationIcon = { IconButton( - onClick = onBackPressed, + onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) } @@ -219,8 +219,8 @@ private fun DeveloperSettingsContent( PreferenceCategory(title = stringResource(R.string.preference_developer_category_auth)) { ExpandablePreferenceItem(title = stringResource(R.string.preference_custom_login_title)) { CustomLoginBottomSheet( - onDismissRequested = ::dismiss, - onRestartRequiredRequested = { + onDismissRequest = ::dismiss, + onRequestRestart = { dismiss() onInteraction(DeveloperSettingsInteraction.RestartRequired) } @@ -298,8 +298,8 @@ private fun CustomRecentMessagesHostBottomSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CustomLoginBottomSheet( - onDismissRequested: () -> Unit, - onRestartRequiredRequested: () -> Unit, + onDismissRequest: () -> Unit, + onRequestRestart: () -> Unit, ) { val scope = rememberCoroutineScope() val customLoginViewModel = koinInject() @@ -317,12 +317,12 @@ private fun CustomLoginBottomSheet( LaunchedEffect(state) { if (state is CustomLoginState.Validated) { - onRestartRequiredRequested() + onRequestRestart() } } ModalBottomSheet( - onDismissRequest = onDismissRequested, + onDismissRequest = onDismissRequest, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column(Modifier.padding(horizontal = 16.dp)) { @@ -414,17 +414,17 @@ private fun CustomLoginBottomSheet( if (showScopesDialog) { ShowScopesBottomSheet( scopes = customLoginViewModel.getScopes(), - onDismissRequested = { showScopesDialog = false }, + onDismissRequest = { showScopesDialog = false }, ) } if (state is CustomLoginState.MissingScopes && state.dialogOpen) { MissingScopesDialog( missing = state.missingScopes, - onDismissRequested = { customLoginViewModel.dismissMissingScopesDialog() }, - onContinueRequested = { + onDismissRequest = { customLoginViewModel.dismissMissingScopesDialog() }, + onContinue = { customLoginViewModel.saveLogin(state.token, state.validation) - onRestartRequiredRequested() + onRequestRestart() }, ) } @@ -432,11 +432,11 @@ private fun CustomLoginBottomSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ShowScopesBottomSheet(scopes: String, onDismissRequested: () -> Unit) { +private fun ShowScopesBottomSheet(scopes: String, onDismissRequest: () -> Unit) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() ModalBottomSheet( - onDismissRequest = onDismissRequested, + onDismissRequest = onDismissRequest, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { @@ -470,12 +470,12 @@ private fun ShowScopesBottomSheet(scopes: String, onDismissRequested: () -> Unit } @Composable -private fun MissingScopesDialog(missing: String, onDismissRequested: () -> Unit, onContinueRequested: () -> Unit) { +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 = onContinueRequested, - onDismiss = onDismissRequested, + onConfirm = onContinue, + onDismiss = onDismissRequest, ) } 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 8728f2c1b..2941685e2 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 @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.api.auth.AuthApiClient import com.flxrs.dankchat.data.auth.AuthDataStore -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.ui.onboarding.OnboardingDataStore import com.flxrs.dankchat.utils.extensions.withTrailingSlash import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix @@ -21,7 +20,6 @@ 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, 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..39e7a56c0 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 @@ -137,7 +137,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/highlights/HighlightsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsScreen.kt index 7f026d011..31a7522d2 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 @@ -110,7 +110,7 @@ fun HighlightsScreen(onNavBack: () -> Unit) { onRemove = viewModel::removeHighlight, onAddNew = viewModel::addHighlight, onAdd = viewModel::addHighlightItem, - onPageChanged = viewModel::setCurrentTab, + onPageChange = viewModel::setCurrentTab, onNavBack = onNavBack, ) } @@ -127,7 +127,7 @@ private fun HighlightsScreen( onRemove: (HighlightItem) -> Unit, onAddNew: () -> Unit, onAdd: (HighlightItem, Int) -> Unit, - onPageChanged: (Int) -> Unit, + onPageChange: (Int) -> Unit, onNavBack: () -> Unit, ) { val focusManager = LocalFocusManager.current @@ -142,7 +142,7 @@ private fun HighlightsScreen( LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { snackbarHost.currentSnackbarData?.dismiss() - onPageChanged(it) + onPageChange(it) } } @@ -265,7 +265,7 @@ private fun HighlightsScreen( ) { idx, item -> MessageHighlightItem( item = item, - onChanged = { messageHighlights[idx] = it }, + onChange = { messageHighlights[idx] = it }, onRemove = { onRemove(messageHighlights[idx]) }, modifier = Modifier.animateItem( fadeInSpec = null, @@ -280,7 +280,7 @@ private fun HighlightsScreen( ) { idx, item -> UserHighlightItem( item = item, - onChanged = { userHighlights[idx] = it }, + onChange = { userHighlights[idx] = it }, onRemove = { onRemove(userHighlights[idx]) }, modifier = Modifier.animateItem( fadeInSpec = null, @@ -295,7 +295,7 @@ private fun HighlightsScreen( ) { idx, item -> BadgeHighlightItem( item = item, - onChanged = { badgeHighlights[idx] = it }, + onChange = { badgeHighlights[idx] = it }, onRemove = { onRemove(badgeHighlights[idx]) }, modifier = Modifier.animateItem( fadeInSpec = null, @@ -310,7 +310,7 @@ private fun HighlightsScreen( ) { idx, item -> BlacklistedUserItem( item = item, - onChanged = { blacklistedUsers[idx] = it }, + onChange = { blacklistedUsers[idx] = it }, onRemove = { onRemove(blacklistedUsers[idx]) }, modifier = Modifier.animateItem( fadeInSpec = null, @@ -341,7 +341,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") { @@ -353,7 +353,7 @@ private fun HighlightsList( @Composable private fun MessageHighlightItem( item: MessageHighlightItem, - onChanged: (MessageHighlightItem) -> Unit, + onChange: (MessageHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -394,7 +394,7 @@ private fun MessageHighlightItem( .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, @@ -409,14 +409,14 @@ private fun MessageHighlightItem( 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( @@ -430,7 +430,7 @@ private fun MessageHighlightItem( 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), ) @@ -440,7 +440,7 @@ 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, ) } @@ -457,7 +457,7 @@ private fun MessageHighlightItem( color = item.customColor ?: defaultColor, defaultColor = defaultColor, enabled = item.enabled, - onColorSelected = { onChanged(item.copy(customColor = it)) }, + onColorSelect = { onChange(item.copy(customColor = it)) }, ) } } @@ -465,7 +465,7 @@ private fun MessageHighlightItem( @Composable private fun UserHighlightItem( item: UserHighlightItem, - onChanged: (UserHighlightItem) -> Unit, + onChange: (UserHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -479,7 +479,7 @@ private fun UserHighlightItem( 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, @@ -491,13 +491,13 @@ 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, ) } @@ -506,7 +506,7 @@ private fun UserHighlightItem( color = item.customColor ?: defaultColor, defaultColor = defaultColor, enabled = item.enabled, - onColorSelected = { onChanged(item.copy(customColor = it)) }, + onColorSelect = { onChange(item.copy(customColor = it)) }, ) } IconButton( @@ -520,7 +520,7 @@ private fun UserHighlightItem( @Composable private fun BadgeHighlightItem( item: BadgeHighlightItem, - onChanged: (BadgeHighlightItem) -> Unit, + onChange: (BadgeHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -535,7 +535,7 @@ private fun BadgeHighlightItem( 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, @@ -572,13 +572,13 @@ 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, ) } @@ -587,7 +587,7 @@ private fun BadgeHighlightItem( color = item.customColor ?: defaultColor, defaultColor = defaultColor, enabled = item.enabled, - onColorSelected = { onChanged(item.copy(customColor = it)) }, + onColorSelect = { onChange(item.copy(customColor = it)) }, ) } if (item.isCustom) { @@ -603,7 +603,7 @@ private fun BadgeHighlightItem( @Composable private fun BlacklistedUserItem( item: BlacklistedUserItem, - onChanged: (BlacklistedUserItem) -> Unit, + onChange: (BlacklistedUserItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -618,7 +618,7 @@ private fun BlacklistedUserItem( 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, @@ -629,13 +629,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( @@ -659,7 +659,7 @@ private fun HighlightColorPicker( color: Int, defaultColor: Int, enabled: Boolean, - onColorSelected: (Int) -> Unit, + onColorSelect: (Int) -> Unit, ) { var showColorPicker by remember { mutableStateOf(false) } var selectedColor by remember(color) { mutableIntStateOf(color) } @@ -682,7 +682,7 @@ private fun HighlightColorPicker( ModalBottomSheet( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), onDismissRequest = { - onColorSelected(selectedColor) + onColorSelect(selectedColor) showColorPicker = false }, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, 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 1e9ac5ab8..5650fe789 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 @@ -102,7 +102,7 @@ fun IgnoresScreen(onNavBack: () -> Unit) { onRemove = viewModel::removeIgnore, onAddNew = viewModel::addIgnore, onAdd = viewModel::addIgnoreItem, - onPageChanged = viewModel::setCurrentTab, + onPageChange = viewModel::setCurrentTab, onNavBack = onNavBack, ) } @@ -118,7 +118,7 @@ private fun IgnoresScreen( onRemove: (IgnoreItem) -> Unit, onAddNew: () -> Unit, onAdd: (IgnoreItem, Int) -> Unit, - onPageChanged: (Int) -> Unit, + onPageChange: (Int) -> Unit, onNavBack: () -> Unit, ) { val resources = LocalResources.current @@ -134,7 +134,7 @@ private fun IgnoresScreen( LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { snackbarHost.currentSnackbarData?.dismiss() - onPageChanged(it) + onPageChange(it) } } @@ -277,7 +277,7 @@ private fun IgnoresScreen( ) { idx, item -> MessageIgnoreItem( item = item, - onChanged = { messageIgnores[idx] = it }, + onChange = { messageIgnores[idx] = it }, onRemove = { onRemove(item) }, modifier = Modifier.animateItem( fadeInSpec = null, @@ -293,7 +293,7 @@ private fun IgnoresScreen( ) { idx, item -> UserIgnoreItem( item = item, - onChanged = { userIgnores[idx] = it }, + onChange = { userIgnores[idx] = it }, onRemove = { onRemove(item) }, modifier = Modifier.animateItem( fadeInSpec = null, @@ -340,7 +340,7 @@ 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") { @@ -356,7 +356,7 @@ private fun IgnoresList( @Composable private fun MessageIgnoreItem( item: MessageIgnoreItem, - onChanged: (MessageIgnoreItem) -> Unit, + onChange: (MessageIgnoreItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -395,7 +395,7 @@ private fun MessageIgnoreItem( .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, @@ -410,14 +410,14 @@ private fun MessageIgnoreItem( 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( @@ -431,14 +431,14 @@ private fun MessageIgnoreItem( 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), ) @@ -450,7 +450,7 @@ private fun MessageIgnoreItem( .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, @@ -462,7 +462,7 @@ private fun MessageIgnoreItem( @Composable private fun UserIgnoreItem( item: UserIgnoreItem, - onChanged: (UserIgnoreItem) -> Unit, + onChange: (UserIgnoreItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -478,7 +478,7 @@ private fun UserIgnoreItem( 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, @@ -490,13 +490,13 @@ 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( @@ -510,7 +510,7 @@ private fun UserIgnoreItem( 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), ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt index 1e4cd3614..e33858bd9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -65,9 +65,9 @@ sealed interface SettingsNavigation { fun OverviewSettingsScreen( isLoggedIn: Boolean, hasChangelog: Boolean, - onBackPressed: () -> Unit, - onLogoutRequested: () -> Unit, - onNavigateRequested: (SettingsNavigation) -> Unit, + onBack: () -> Unit, + onLogout: () -> Unit, + onNavigate: (SettingsNavigation) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -81,7 +81,7 @@ fun OverviewSettingsScreen( title = { Text(stringResource(R.string.settings)) }, navigationIcon = { IconButton( - onClick = onBackPressed, + onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }, ) } @@ -98,33 +98,33 @@ fun OverviewSettingsScreen( PreferenceItem( title = stringResource(R.string.preference_appearance_header), icon = Icons.Default.Palette, - onClick = { onNavigateRequested(SettingsNavigation.Appearance) }, + onClick = { onNavigate(SettingsNavigation.Appearance) }, ) PreferenceItem( title = stringResource(R.string.preference_highlights_ignores_header), icon = Icons.Default.NotificationsActive, - onClick = { onNavigateRequested(SettingsNavigation.Notifications) }, + onClick = { onNavigate(SettingsNavigation.Notifications) }, ) PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { - onNavigateRequested(SettingsNavigation.Chat) + onNavigate(SettingsNavigation.Chat) }) PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { - onNavigateRequested(SettingsNavigation.Streams) + onNavigate(SettingsNavigation.Streams) }) PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { - onNavigateRequested(SettingsNavigation.Tools) + onNavigate(SettingsNavigation.Tools) }) PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { - onNavigateRequested(SettingsNavigation.Developer) + onNavigate(SettingsNavigation.Developer) }) AnimatedVisibility(hasChangelog) { PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { - onNavigateRequested(SettingsNavigation.Changelog) + 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 = { @@ -152,7 +152,7 @@ fun OverviewSettingsScreen( appendLine() appendLine() val licenseText = stringResource(R.string.open_source_licenses) - withLink(link = buildClickableAnnotation(text = licenseText, onClick = { onNavigateRequested(SettingsNavigation.About) })) { + withLink(link = buildClickableAnnotation(text = licenseText, onClick = { onNavigate(SettingsNavigation.About) })) { append(licenseText) } } @@ -164,6 +164,7 @@ fun OverviewSettingsScreen( } } +@Suppress("UnusedPrivateMember") @Composable @PreviewDynamicColors @PreviewLightDark @@ -172,9 +173,9 @@ private fun OverviewSettingsPreview() { OverviewSettingsScreen( isLoggedIn = false, hasChangelog = true, - onBackPressed = { }, - onLogoutRequested = { }, - onNavigateRequested = { }, + onBack = { }, + onLogout = { }, + onNavigate = { }, ) } } 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..4fe2f6b38 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,6 +21,7 @@ interface SecretDankerScope { fun Modifier.dankClickable(): Modifier } +@Suppress("ContentSlotReused") @Composable fun SecretDankerModeTrigger(content: @Composable SecretDankerScope.() -> Unit) { if (LocalInspectionMode.current) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt index 55c483f7b..b25eb1580 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt @@ -33,7 +33,7 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun StreamsSettingsScreen( - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value @@ -41,7 +41,7 @@ fun StreamsSettingsScreen( StreamsSettingsContent( settings = settings, onInteraction = { viewModel.onInteraction(it) }, - onBackPressed = onBackPressed + onBack = onBack ) } @@ -49,7 +49,7 @@ fun StreamsSettingsScreen( private fun StreamsSettingsContent( settings: StreamsSettings, onInteraction: (StreamsSettingsInteraction) -> Unit, - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -61,7 +61,7 @@ private fun StreamsSettingsContent( 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)) }, ) } 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 218d790ab..72910f90f 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 @@ -311,7 +311,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) @@ -324,7 +324,7 @@ fun TextToSpeechCategory( entries = formatEntries, selected = settings.ttsMessageFormat, isEnabled = settings.ttsEnabled, - onChanged = { onInteraction(ToolsSettingsInteraction.TTSFormat(it)) }, + onChange ={ onInteraction(ToolsSettingsInteraction.TTSFormat(it)) }, ) SwitchPreferenceItem( 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 70476ed5d..b0346e9ba 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 @@ -138,10 +138,10 @@ private fun UserIgnoreListScreen( .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) @@ -177,7 +177,7 @@ private fun UserIgnoreListScreen( @Composable private fun UserIgnoreItem( user: String, - onUserChanged: (String) -> Unit, + onUserChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -189,7 +189,7 @@ private fun UserIgnoreItem( .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/ui/changelog/ChangelogScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt index e6f8e78f5..9a62d81c1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt @@ -29,7 +29,7 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun ChangelogScreen( - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val viewModel: ChangelogSheetViewModel = koinViewModel() val state = viewModel.state ?: return @@ -52,7 +52,7 @@ fun ChangelogScreen( }, navigationIcon = { IconButton( - onClick = onBackPressed, + onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) } @@ -75,4 +75,4 @@ fun ChangelogScreen( } } } -} \ No newline at end of file +} 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 index d65cc2043..d8e1b551f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -35,16 +35,16 @@ fun ChatComposable( onEmoteClick: (List) -> Unit, onReplyClick: (String, UserName) -> Unit, modifier: Modifier = Modifier, + scrollModifier: Modifier = Modifier, showInput: Boolean = true, isFullscreen: Boolean = false, showFabs: Boolean = true, onRecover: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(), - scrollModifier: Modifier = Modifier, onScrollToBottom: () -> Unit = {}, - onScrollDirectionChanged: (Boolean) -> Unit = {}, + onScrollDirectionChange: (Boolean) -> Unit = {}, scrollToMessageId: String? = null, - onScrollToMessageHandled: () -> Unit = {}, + onScrollToMessageHandle: () -> Unit = {}, recoveryFabTooltipState: TooltipState? = null, onTourAdvance: (() -> Unit)? = null, onTourSkip: (() -> Unit)? = null, @@ -84,9 +84,9 @@ fun ChatComposable( contentPadding = contentPadding, scrollModifier = scrollModifier, onScrollToBottom = onScrollToBottom, - onScrollDirectionChanged = onScrollDirectionChanged, + onScrollDirectionChange = onScrollDirectionChange, scrollToMessageId = scrollToMessageId, - onScrollToMessageHandled = onScrollToMessageHandled, + 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 index c680dd107..6c49d3cd9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -25,7 +25,6 @@ import com.flxrs.dankchat.data.twitch.message.highestPriorityHighlight 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.appearance.AppearanceSettings import com.flxrs.dankchat.preferences.chat.ChatSettings import com.flxrs.dankchat.utils.DateTimeUtils import com.flxrs.dankchat.utils.TextResource @@ -45,7 +44,6 @@ class ChatMessageMapper( fun mapToUiState( item: ChatItem, - appearanceSettings: AppearanceSettings, chatSettings: ChatSettings, preferenceStore: DankChatPreferenceStore, isAlternateBackground: Boolean, @@ -80,7 +78,6 @@ class ChatMessageMapper( is PrivMessage -> msg.toPrivMessageUi( tag = item.tag, - appearanceSettings = appearanceSettings, chatSettings = chatSettings, isAlternateBackground = isAlternateBackground, isMentionTab = item.isMentionTab, @@ -91,7 +88,6 @@ class ChatMessageMapper( is AutomodMessage -> msg.toAutomodMessageUi( tag = item.tag, chatSettings = chatSettings, - isAlternateBackground = isAlternateBackground, textAlpha = textAlpha ) @@ -279,7 +275,6 @@ class ChatMessageMapper( private fun AutomodMessage.toAutomodMessageUi( tag: Int, chatSettings: ChatSettings, - isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.AutomodMessageUi { val timestamp = if (chatSettings.showTimestamps) { @@ -324,7 +319,6 @@ class ChatMessageMapper( private fun PrivMessage.toPrivMessageUi( tag: Int, - appearanceSettings: AppearanceSettings, chatSettings: ChatSettings, isAlternateBackground: Boolean, isMentionTab: Boolean, 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 index 3995cf905..719ddc7d2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -78,6 +78,7 @@ fun ChatScreen( fontSize: Float, callbacks: ChatScreenCallbacks, modifier: Modifier = Modifier, + scrollModifier: Modifier = Modifier, showChannelPrefix: Boolean = false, showLineSeparator: Boolean = false, animateGifs: Boolean = true, @@ -85,11 +86,10 @@ fun ChatScreen( isFullscreen: Boolean = false, onRecover: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(), - scrollModifier: Modifier = Modifier, onScrollToBottom: () -> Unit = {}, - onScrollDirectionChanged: (isScrollingUp: Boolean) -> Unit = {}, + onScrollDirectionChange: (isScrollingUp: Boolean) -> Unit = {}, scrollToMessageId: String? = null, - onScrollToMessageHandled: () -> Unit = {}, + onScrollToMessageHandle: () -> Unit = {}, containerColor: Color = MaterialTheme.colorScheme.background, showFabs: Boolean = true, recoveryFabTooltipState: TooltipState? = null, @@ -119,7 +119,7 @@ fun ChatScreen( if (!listState.isScrollInProgress && isAtBottom && !shouldAutoScroll) { shouldAutoScroll = true } - onScrollDirectionChanged(listState.lastScrolledForward) + onScrollDirectionChange(listState.lastScrolledForward) } // Auto-scroll when new messages arrive or when re-enabled @@ -145,7 +145,7 @@ fun ChatScreen( val bottomPaddingPx = with(density) { contentPadding.calculateBottomPadding().roundToPx() } listState.scrollToCentered(index, topPaddingPx, bottomPaddingPx) } - onScrollToMessageHandled() + onScrollToMessageHandle() } Surface( @@ -255,7 +255,7 @@ fun ChatScreen( FloatingActionButton( onClick = { shouldAutoScroll = true - onScrollDirectionChanged(false) + onScrollDirectionChange(false) onScrollToBottom() }, ) { 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 index 61cac23ed..fcb5c26bb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -106,7 +106,6 @@ class ChatViewModel( val mapped = mappingCache.getOrPut(cacheKey) { chatMessageMapper.mapToUiState( item = item, - appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, isAlternateBackground = altBg diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt index 0d75d00c5..469aaef82 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt @@ -84,7 +84,7 @@ class EmoteAnimationCoordinator( } else { null } - } catch (e: Exception) { + } catch (_: Exception) { null } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt index 9187fe713..eb02ee27a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt @@ -28,14 +28,6 @@ object EmoteScaling { return (fontSizeSp * BASE_HEIGHT_CONSTANT).dp } - /** - * Calculate scale factor exactly as ChatAdapter did from fontSize in SP. - */ - private fun getScaleFactor(fontSizeSp: Float): Double { - val baseHeight = fontSizeSp * BASE_HEIGHT_CONSTANT - return baseHeight * SCALE_FACTOR_CONSTANT - } - /** * Calculate scale factor from base height in pixels. */ diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt index 68d623cbd..1f7e7ec70 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt @@ -92,7 +92,7 @@ fun StackedEmote( result.image?.asDrawable(context.resources)?.let { drawable -> transformEmoteDrawable(drawable, scaleFactor, emoteData) } - } catch (e: Exception) { + } catch (_: Exception) { null } }.toTypedArray() @@ -189,7 +189,7 @@ private fun SingleEmoteDrawable( ) value = transformed } - } catch (e: Exception) { + } catch (_: Exception) { // Ignore errors } } 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 index 6baf2834e..e113ad832 100644 --- 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 @@ -91,7 +91,6 @@ class MessageHistoryViewModel( val altBg = index.isEven && appearanceSettings.checkeredMessages chatMessageMapper.mapToUiState( item = item, - appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, isAlternateBackground = altBg, 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 index dbe1c007d..68ae01cbc 100644 --- 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 @@ -33,11 +33,11 @@ fun MentionComposable( 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, - containerColor: Color, contentPadding: PaddingValues = PaddingValues(), - scrollModifier: Modifier = Modifier, onScrollToBottom: () -> Unit = {}, ) { val displaySettings by mentionViewModel.chatDisplaySettings.collectAsStateWithLifecycle() 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 index eee4e62ab..754b29266 100644 --- 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 @@ -68,7 +68,6 @@ class MentionViewModel( val altBg = index.isEven && appearanceSettings.checkeredMessages chatMessageMapper.mapToUiState( item = item, - appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, isAlternateBackground = altBg @@ -85,7 +84,6 @@ class MentionViewModel( val altBg = index.isEven && appearanceSettings.checkeredMessages chatMessageMapper.mapToUiState( item = item, - appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, isAlternateBackground = altBg 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 index 9ccf63675..f67b2542f 100644 --- 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 @@ -57,18 +57,19 @@ import com.flxrs.dankchat.utils.resolve * - Clickable username and emotes * - Long-press to copy message */ +@Suppress("LambdaParameterEventTrailing") @Composable fun PrivMessageComposable( message: ChatMessageUiState.PrivMessageUi, fontSize: Float, - modifier: Modifier = Modifier, - highlightShape: Shape = RectangleShape, - showChannelPrefix: Boolean = false, - animateGifs: Boolean = true, 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) 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 index b07c89cb4..c91d9560f 100644 --- 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 @@ -54,11 +54,11 @@ import com.flxrs.dankchat.ui.chat.rememberNormalizedColor fun WhisperMessageComposable( message: ChatMessageUiState.WhisperMessageUi, fontSize: Float, - modifier: Modifier = Modifier, - animateGifs: Boolean = true, 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() } 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 index bde45719a..d7c874701 100644 --- 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 @@ -23,7 +23,7 @@ import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator * This composable: * - Collects reply thread state from RepliesViewModel * - Collects appearance settings - * - Handles NotFound state via onNotFound callback + * - Handles NotFound state via onMissing callback * - Renders ChatScreen for Found state */ @Composable @@ -31,11 +31,11 @@ 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, - onNotFound: () -> Unit, + onMissing: () -> Unit, containerColor: Color, modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(), scrollModifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), onScrollToBottom: () -> Unit = {}, ) { val displaySettings by repliesViewModel.chatDisplaySettings.collectAsStateWithLifecycle() @@ -66,7 +66,7 @@ fun RepliesComposable( is RepliesUiState.NotFound -> { LaunchedEffect(Unit) { - onNotFound() + 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 index 948ddfc30..a2a064976 100644 --- 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 @@ -14,4 +14,4 @@ sealed interface RepliesState { sealed interface RepliesUiState { data object NotFound : RepliesUiState data class Found(val items: List) : RepliesUiState -} \ No newline at end of file +} 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 index 7e30dbe67..fed13f1d9 100644 --- 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 @@ -61,7 +61,6 @@ class RepliesViewModel( val altBg = index.isEven && appearanceSettings.checkeredMessages chatMessageMapper.mapToUiState( item = item, - appearanceSettings = appearanceSettings, chatSettings = chatSettings, preferenceStore = preferenceStore, isAlternateBackground = altBg 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 index 883545503..866076e19 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -127,6 +127,7 @@ sealed interface ToolbarAction { data object OpenSettings : ToolbarAction } +@Suppress("MultipleEmitters") @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun FloatingToolbar( @@ -136,7 +137,6 @@ fun FloatingToolbar( isFullscreen: Boolean, isLoggedIn: Boolean, currentStream: UserName?, - hasStreamData: Boolean, streamHeightDp: Dp, totalMentionCount: Int, onAction: (ToolbarAction) -> Unit, @@ -144,9 +144,8 @@ fun FloatingToolbar( endAligned: Boolean = false, showTabs: Boolean = true, addChannelTooltipState: TooltipState? = null, - onAddChannelTooltipDismissed: () -> Unit = {}, + onAddChannelTooltipDismiss: () -> Unit = {}, onSkipTour: () -> Unit = {}, - isKeyboardVisible: Boolean = false, keyboardHeightDp: Dp = 0.dp, streamToolbarAlpha: Float = 1f, ) { @@ -551,7 +550,7 @@ fun FloatingToolbar( snapshotFlow { addChannelTooltipState.isVisible } .dropWhile { !it } // skip initial false .first { !it } // wait for dismiss (any cause) - onAddChannelTooltipDismissed() + onAddChannelTooltipDismiss() } TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider( @@ -570,10 +569,10 @@ fun FloatingToolbar( caretShape = TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)), action = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed(); onSkipTour() }) { + TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismiss(); onSkipTour() }) { Text(stringResource(R.string.tour_skip)) } - TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismissed() }) { + TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismiss() }) { Text(stringResource(R.string.tour_next)) } } 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 index f7a0a6da0..7426df9e8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -222,7 +222,6 @@ class MainActivity : AppCompatActivity() { popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(300)) + fadeIn(animationSpec = tween(300)) }, ) { MainScreen( - navController = navController, isLoggedIn = isLoggedIn, onNavigateToSettings = { navController.navigate(Settings) @@ -295,14 +294,14 @@ class MainActivity : AppCompatActivity() { OverviewSettingsScreen( isLoggedIn = isLoggedIn, hasChangelog = com.flxrs.dankchat.ui.changelog.DankChatVersion.HAS_CHANGELOG, - onBackPressed = { navController.popBackStack() }, - onLogoutRequested = { + onBack = { navController.popBackStack() }, + onLogout = { lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.LogOutRequested) navController.popBackStack() } }, - onNavigateRequested = { destination -> + onNavigate = { destination -> when (destination) { SettingsNavigation.Appearance -> navController.navigate(AppearanceSettings) SettingsNavigation.Notifications -> navController.navigate(NotificationsSettings) @@ -337,7 +336,7 @@ class MainActivity : AppCompatActivity() { popExitTransition = subPopExit ) { AppearanceSettingsScreen( - onBackPressed = { navController.popBackStack() } + onBack = { navController.popBackStack() } ) } composable( @@ -411,7 +410,7 @@ class MainActivity : AppCompatActivity() { popExitTransition = subPopExit ) { StreamsSettingsScreen( - onBackPressed = { navController.popBackStack() } + onBack = { navController.popBackStack() } ) } composable( @@ -453,7 +452,7 @@ class MainActivity : AppCompatActivity() { popExitTransition = subPopExit ) { DeveloperSettingsScreen( - onBackPressed = { navController.popBackStack() } + onBack = { navController.popBackStack() } ) } composable( @@ -463,7 +462,7 @@ class MainActivity : AppCompatActivity() { popExitTransition = subPopExit ) { ChangelogScreen( - onBackPressed = { navController.popBackStack() } + onBack = { navController.popBackStack() } ) } composable( @@ -473,7 +472,7 @@ class MainActivity : AppCompatActivity() { popExitTransition = subPopExit ) { AboutScreen( - onBackPressed = { navController.popBackStack() } + onBack = { navController.popBackStack() } ) } } 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 index d396c5750..0ff0f5b7f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -79,8 +79,8 @@ sealed interface AppBarMenu { fun InlineOverflowMenu( isLoggedIn: Boolean, onDismiss: () -> Unit, - initialMenu: AppBarMenu = AppBarMenu.Main, onAction: (ToolbarAction) -> Unit, + initialMenu: AppBarMenu = AppBarMenu.Main, keyboardHeightDp: Dp = 0.dp, ) { var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } 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 index d3516bd7d..c4ff7a54a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -90,7 +90,6 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import androidx.window.core.layout.WindowSizeClass import com.flxrs.dankchat.R import com.flxrs.dankchat.data.DisplayName @@ -138,10 +137,10 @@ import org.koin.compose.viewmodel.koinViewModel private val ROUNDED_CORNER_THRESHOLD = 8.dp +@Suppress("ModifierNotUsedAtRoot") @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun MainScreen( - navController: NavController, isLoggedIn: Boolean, onNavigateToSettings: () -> Unit, onLogin: () -> Unit, @@ -268,7 +267,7 @@ fun MainScreen( } chatInputViewModel.setEmoteMenuOpen(false) backProgress = 0f - } catch (e: Exception) { + } catch (_: Exception) { backProgress = 0f } } @@ -543,13 +542,13 @@ fun MainScreen( } }, onModActions = dialogViewModel::showModActions, - onInputActionsChanged = mainScreenViewModel::updateInputActions, + onInputActionsChange = mainScreenViewModel::updateInputActions, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper } else null, - onRepeatedSendChanged = chatInputViewModel::setRepeatedSend, + onRepeatedSendChange = chatInputViewModel::setRepeatedSend, ), isUploading = dialogState.isUploading, isLoading = tabState.loading, @@ -569,11 +568,11 @@ fun MainScreen( is FullScreenSheetState.History, is FullScreenSheetState.Closed -> mainState.inputActions }, - onInputHeightChanged = { inputHeightPx = it }, + onInputHeightChange = { inputHeightPx = it }, debugMode = mainState.debugMode, overflowExpanded = inputOverflowExpanded, - onOverflowExpandedChanged = { inputOverflowExpanded = it }, - onHelperTextHeightChanged = { helperTextHeightPx = it }, + onOverflowExpandedChange = { inputOverflowExpanded = it }, + onHelperTextHeightChange = { helperTextHeightPx = it }, isInSplitLayout = useWideSplitLayout, instantHide = isHistorySheet, isRepeatedSendEnabled = mainState.isRepeatedSendEnabled, @@ -659,16 +658,14 @@ fun MainScreen( isFullscreen = isFullscreen, isLoggedIn = isLoggedIn, currentStream = currentStream, - hasStreamData = hasStreamData, streamHeightDp = streamState.heightDp, totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, onAction = handleToolbarAction, endAligned = endAligned, showTabs = showTabs, addChannelTooltipState = if (featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) featureTourViewModel.addChannelTooltipState else null, - onAddChannelTooltipDismissed = featureTourViewModel::onToolbarHintDismissed, + onAddChannelTooltipDismiss = featureTourViewModel::onToolbarHintDismissed, onSkipTour = featureTourViewModel::skipTour, - isKeyboardVisible = isKeyboardVisible, keyboardHeightDp = with(density) { currentImeHeight.toDp() }, streamToolbarAlpha = streamState.effectiveAlpha, modifier = toolbarModifier, @@ -807,9 +804,9 @@ fun MainScreen( ), scrollModifier = chatScrollModifier, onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, - onScrollDirectionChanged = { }, + onScrollDirectionChange = { }, scrollToMessageId = scrollTargets[channel], - onScrollToMessageHandled = { scrollTargets.remove(channel) }, + onScrollToMessageHandle = { scrollTargets.remove(channel) }, recoveryFabTooltipState = if (featureTourState.currentTourStep == TourStep.RecoveryFab) featureTourViewModel.recoveryFabTooltipState else null, onTourAdvance = featureTourViewModel::advance, onTourSkip = featureTourViewModel::skipTour, @@ -862,7 +859,6 @@ fun MainScreen( } FullScreenSheetOverlay( sheetState = fullScreenSheetState, - isLoggedIn = isLoggedIn, mentionViewModel = mentionViewModel, onDismiss = sheetNavigationViewModel::closeFullScreenSheet, onDismissReplies = { 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 index caa731271..9bae50e26 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt @@ -3,4 +3,4 @@ package com.flxrs.dankchat.ui.main import androidx.compose.runtime.Immutable @Immutable -data class RepeatedSendData(val enabled: Boolean, val message: String) \ No newline at end of file +data class RepeatedSendData(val enabled: Boolean, val message: String) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt index 7064c907f..b73f533c9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt @@ -11,7 +11,7 @@ import kotlinx.collections.immutable.ImmutableList fun ChannelTabRow( tabs: ImmutableList, selectedIndex: Int, - onTabSelected: (Int) -> Unit + onTabSelect: (Int) -> Unit ) { PrimaryScrollableTabRow( selectedTabIndex = selectedIndex, @@ -20,7 +20,7 @@ fun ChannelTabRow( ChannelTab( tab = tab, onClick = { - onTabSelected(index) + onTabSelect(index) } ) } 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 index fc50798e0..facc44c40 100644 --- 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 @@ -97,7 +97,7 @@ fun MainScreenDialogs( ManageChannelsDialog( channels = channels, onApplyChanges = channelManagementViewModel::applyChanges, - onChannelSelected = channelManagementViewModel::selectChannel, + onChannelSelect = channelManagementViewModel::selectChannel, onDismiss = dialogViewModel::dismissManageChannels ) } @@ -218,9 +218,7 @@ fun MainScreenDialogs( val state by viewModel.state.collectAsStateWithLifecycle() (state as? MessageOptionsState.Found)?.let { s -> MessageOptionsDialog( - messageId = s.messageId, channel = params.channel?.value, - fullMessage = params.fullMessage, canModerate = s.canModerate, canReply = s.canReply, canCopy = params.canCopy, 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 index d6f6e1206..6c3b36a4f 100644 --- 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 @@ -69,7 +69,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState fun ManageChannelsDialog( channels: List, onApplyChanges: (List) -> Unit, - onChannelSelected: (UserName) -> Unit, + onChannelSelect: (UserName) -> Unit, onDismiss: () -> Unit, ) { var channelToDelete by remember { mutableStateOf(null) } @@ -109,7 +109,7 @@ fun ManageChannelsDialog( state = lazyListState, contentPadding = navBarPadding, ) { - itemsIndexed(localChannels, key = { _, it -> it.channel.value }) { index, channelWithRename -> + 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) @@ -130,7 +130,7 @@ fun ManageChannelsDialog( ), onNavigate = { onApplyChanges(localChannels.toList()) - onChannelSelected(channelWithRename.channel) + onChannelSelect(channelWithRename.channel) onDismiss() }, onEdit = { @@ -185,19 +185,20 @@ fun ManageChannelsDialog( } } +@Suppress("LambdaParameterEventTrailing") @Composable private fun ChannelItem( channelWithRename: ChannelWithRename, isEditing: Boolean, - modifier: Modifier = Modifier, onNavigate: () -> Unit, onEdit: () -> Unit, onRename: (String?) -> Unit, onDelete: () -> Unit, + modifier: Modifier = Modifier, ) { - Column { + Column(modifier = modifier) { Row( - modifier = modifier + modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically 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 index 46c60c5ce..06e2be644 100644 --- 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 @@ -63,9 +63,7 @@ private enum class MessageOptionsSubView { @OptIn(ExperimentalMaterial3Api::class) @Composable fun MessageOptionsDialog( - messageId: String, channel: String?, - fullMessage: String, canModerate: Boolean, canReply: Boolean, canCopy: Boolean, 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 index 836e66865..9890d5a1a 100644 --- 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 @@ -552,9 +552,9 @@ private fun FollowerPresetChips( private fun UserInputSubView( titleRes: Int, hintRes: Int, + onConfirm: (String) -> Unit, defaultValue: String = "", keyboardType: KeyboardType = KeyboardType.Text, - onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}, ) { var inputValue by remember { mutableStateOf(TextFieldValue(defaultValue, selection = TextRange(defaultValue.length))) } 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 index af7b6c161..1ed1db539 100644 --- 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 @@ -43,18 +43,18 @@ fun ChatBottomBar( hasStreamData: Boolean, isSheetOpen: Boolean, inputActions: ImmutableList, - onInputHeightChanged: (Int) -> Unit, + onInputHeightChange: (Int) -> Unit, modifier: Modifier = Modifier, debugMode: Boolean = false, overflowExpanded: Boolean = false, - onOverflowExpandedChanged: (Boolean) -> Unit = {}, - onHelperTextHeightChanged: (Int) -> Unit = {}, + onOverflowExpandedChange: (Boolean) -> Unit = {}, + onHelperTextHeightChange: (Int) -> Unit = {}, isInSplitLayout: Boolean = false, instantHide: Boolean = false, tourState: TourOverlayState = TourOverlayState(), isRepeatedSendEnabled: Boolean = false, ) { - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = modifier.fillMaxWidth()) { AnimatedVisibility( visible = showInput, enter = EnterTransition.None, @@ -77,11 +77,11 @@ fun ChatBottomBar( inputActions = inputActions, debugMode = debugMode, overflowExpanded = overflowExpanded, - onOverflowExpandedChanged = onOverflowExpandedChanged, + onOverflowExpandedChange = onOverflowExpandedChange, tourState = tourState, isRepeatedSendEnabled = isRepeatedSendEnabled, modifier = Modifier.onGloballyPositioned { coordinates -> - onInputHeightChanged(coordinates.size.height) + onInputHeightChange(coordinates.size.height) } ) } @@ -107,7 +107,7 @@ fun ChatBottomBar( color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier .fillMaxWidth() - .onGloballyPositioned { onHelperTextHeightChanged(it.size.height) } + .onGloballyPositioned { onHelperTextHeightChange(it.size.height) } ) { Text( text = helperText, 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 index 3537a74e2..4be13df96 100644 --- 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 @@ -131,11 +131,11 @@ data class ChatInputCallbacks( val onToggleInput: () -> Unit, val onToggleStream: () -> Unit, val onModActions: () -> Unit, - val onInputActionsChanged: (ImmutableList) -> Unit, + val onInputActionsChange: (ImmutableList) -> Unit, val onSearchClick: () -> Unit = {}, val onDebugInfoClick: () -> Unit = {}, val onNewWhisper: (() -> Unit)? = null, - val onRepeatedSendChanged: (Boolean) -> Unit = {}, + val onRepeatedSendChange: (Boolean) -> Unit = {}, ) @Composable @@ -154,7 +154,7 @@ fun ChatInputLayout( modifier: Modifier = Modifier, debugMode: Boolean = false, overflowExpanded: Boolean = false, - onOverflowExpandedChanged: (Boolean) -> Unit = {}, + onOverflowExpandedChange: (Boolean) -> Unit = {}, tourState: TourOverlayState = TourOverlayState(), isRepeatedSendEnabled: Boolean = false, ) { @@ -175,11 +175,11 @@ fun ChatInputLayout( val onToggleInput = callbacks.onToggleInput val onToggleStream = callbacks.onToggleStream val onModActions = callbacks.onModActions - val onInputActionsChanged = callbacks.onInputActionsChanged + val onInputActionsChange = callbacks.onInputActionsChange val onSearchClick = callbacks.onSearchClick val onDebugInfoClick = callbacks.onDebugInfoClick val onNewWhisper = callbacks.onNewWhisper - val onRepeatedSendChanged = callbacks.onRepeatedSendChanged + val onRepeatedSendChange = callbacks.onRepeatedSendChange val focusRequester = remember { FocusRequester() } val hint = when (inputState) { @@ -395,7 +395,7 @@ fun ChatInputLayout( isFullscreen = isFullscreen, focusRequester = focusRequester, onEmoteClick = onEmoteClick, - onOverflowExpandedChanged = onOverflowExpandedChanged, + onOverflowExpandedChange = onOverflowExpandedChange, onNewWhisper = onNewWhisper, onSearchClick = onSearchClick, onLastMessageClick = onLastMessageClick, @@ -406,8 +406,8 @@ fun ChatInputLayout( onDebugInfoClick = onDebugInfoClick, onSend = onSend, isRepeatedSendEnabled = isRepeatedSendEnabled, - onRepeatedSendChanged = onRepeatedSendChanged, - onVisibleActionsChanged = { visibleActions = it }, + onRepeatedSendChange = onRepeatedSendChange, + onVisibleActionsChange = { visibleActions = it }, ) } } @@ -443,7 +443,7 @@ fun ChatInputLayout( progress.collect { event -> backProgress = event.progress } - onOverflowExpandedChanged(false) + onOverflowExpandedChange(false) } catch (_: CancellationException) { backProgress = 0f } @@ -474,10 +474,10 @@ fun ChatInputLayout( InputAction.HideInput -> onToggleInput() InputAction.Debug -> onDebugInfoClick() } - onOverflowExpandedChanged(false) + onOverflowExpandedChange(false) }, onConfigureClick = { - onOverflowExpandedChanged(false) + onOverflowExpandedChange(false) showConfigSheet = true }, ) @@ -488,7 +488,7 @@ fun ChatInputLayout( InputActionConfigSheet( inputActions = inputActions, debugMode = debugMode, - onInputActionsChanged = onInputActionsChanged, + onInputActionsChange = onInputActionsChange, onDismiss = { showConfigSheet = false }, ) } @@ -499,7 +499,7 @@ fun ChatInputLayout( private fun InputActionConfigSheet( inputActions: ImmutableList, debugMode: Boolean, - onInputActionsChanged: (ImmutableList) -> Unit, + onInputActionsChange: (ImmutableList) -> Unit, onDismiss: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -511,7 +511,7 @@ private fun InputActionConfigSheet( ModalBottomSheet( onDismissRequest = { - onInputActionsChanged(localEnabled.toImmutableList()) + onInputActionsChange(localEnabled.toImmutableList()) onDismiss() }, sheetState = sheetState, @@ -658,7 +658,7 @@ private fun SendButton( enabled: Boolean, isRepeatedSendEnabled: Boolean, onSend: () -> Unit, - onRepeatedSendChanged: (Boolean) -> Unit, + onRepeatedSendChange: (Boolean) -> Unit, modifier: Modifier = Modifier ) { val contentColor = when { @@ -670,10 +670,10 @@ private fun SendButton( enabled && isRepeatedSendEnabled -> Modifier.pointerInput(Unit) { detectTapGestures( onTap = { onSend() }, - onLongPress = { onRepeatedSendChanged(true) }, + onLongPress = { onRepeatedSendChange(true) }, onPress = { tryAwaitRelease() - onRepeatedSendChanged(false) + onRepeatedSendChange(false) }, ) } @@ -715,8 +715,8 @@ private fun InputActionButton( onModActions: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, - onDebugInfoClick: () -> Unit = {}, modifier: Modifier = Modifier, + onDebugInfoClick: () -> Unit = {}, ) { val (icon, contentDescription, onClick) = when (action) { InputAction.Search -> Triple(Icons.Default.Search, R.string.message_history, onSearchClick) @@ -809,7 +809,7 @@ private fun InputActionsRow( isFullscreen: Boolean, focusRequester: FocusRequester, onEmoteClick: () -> Unit, - onOverflowExpandedChanged: (Boolean) -> Unit, + onOverflowExpandedChange: (Boolean) -> Unit, onNewWhisper: (() -> Unit)?, onSearchClick: () -> Unit, onLastMessageClick: () -> Unit, @@ -817,11 +817,11 @@ private fun InputActionsRow( onModActions: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, - onDebugInfoClick: () -> Unit = {}, onSend: () -> Unit, + onVisibleActionsChange: (ImmutableList) -> Unit, + onDebugInfoClick: () -> Unit = {}, isRepeatedSendEnabled: Boolean = false, - onRepeatedSendChanged: (Boolean) -> Unit = {}, - onVisibleActionsChanged: (ImmutableList) -> Unit, + onRepeatedSendChange: (Boolean) -> Unit = {}, ) { BoxWithConstraints( modifier = Modifier @@ -835,7 +835,7 @@ private fun InputActionsRow( val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) val allActions = inputActions.take(maxVisibleActions).toImmutableList() val visibleActions = effectiveActions.take(maxVisibleActions).toImmutableList() - onVisibleActionsChanged(visibleActions) + onVisibleActionsChange(visibleActions) Row( verticalAlignment = Alignment.CenterVertically, @@ -883,7 +883,7 @@ private fun InputActionsRow( hasLastMessage = hasLastMessage, isStreamActive = isStreamActive, isFullscreen = isFullscreen, - onOverflowExpandedChanged = onOverflowExpandedChanged, + onOverflowExpandedChange = onOverflowExpandedChange, onNewWhisper = onNewWhisper, onSearchClick = onSearchClick, onLastMessageClick = onLastMessageClick, @@ -894,7 +894,7 @@ private fun InputActionsRow( onDebugInfoClick = onDebugInfoClick, onSend = onSend, isRepeatedSendEnabled = isRepeatedSendEnabled, - onRepeatedSendChanged = onRepeatedSendChanged, + onRepeatedSendChange = onRepeatedSendChange, ) } } @@ -902,6 +902,7 @@ private fun InputActionsRow( } } +@Suppress("MultipleEmitters") @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EndAlignedActionGroup( @@ -916,7 +917,7 @@ private fun EndAlignedActionGroup( hasLastMessage: Boolean, isStreamActive: Boolean, isFullscreen: Boolean, - onOverflowExpandedChanged: (Boolean) -> Unit, + onOverflowExpandedChange: (Boolean) -> Unit, onNewWhisper: (() -> Unit)?, onSearchClick: () -> Unit, onLastMessageClick: () -> Unit, @@ -924,10 +925,10 @@ private fun EndAlignedActionGroup( onModActions: () -> Unit, onToggleFullscreen: () -> Unit, onToggleInput: () -> Unit, - onDebugInfoClick: () -> Unit = {}, onSend: () -> Unit, + onDebugInfoClick: () -> Unit = {}, isRepeatedSendEnabled: Boolean = false, - onRepeatedSendChanged: (Boolean) -> Unit = {}, + onRepeatedSendChange: (Boolean) -> Unit = {}, ) { // Overflow Button (leading the end-aligned group) if (showQuickActions) { @@ -937,7 +938,7 @@ private fun EndAlignedActionGroup( if (tourState.overflowMenuTooltipState != null) { tourState.onAdvance?.invoke() } else { - onOverflowExpandedChanged(!quickActionsExpanded) + onOverflowExpandedChange(!quickActionsExpanded) } }, modifier = Modifier.size(iconSize) @@ -1003,11 +1004,12 @@ private fun EndAlignedActionGroup( enabled = canSend, isRepeatedSendEnabled = isRepeatedSendEnabled, onSend = onSend, - onRepeatedSendChanged = onRepeatedSendChanged, + onRepeatedSendChange = onRepeatedSendChange, modifier = Modifier.size(44.dp), ) } +@Suppress("ContentSlotReused") @OptIn(ExperimentalMaterial3Api::class) @Composable private fun OptionalTourTooltip( 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 index 053bd82b2..65d94c893 100644 --- 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 @@ -23,7 +23,6 @@ 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.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore import com.flxrs.dankchat.ui.chat.suggestion.Suggestion @@ -68,7 +67,6 @@ class ChatInputViewModel( private val suggestionProvider: SuggestionProvider, private val preferenceStore: DankChatPreferenceStore, private val chatSettingsDataStore: ChatSettingsDataStore, - private val developerSettingsDataStore: DeveloperSettingsDataStore, private val streamDataRepository: StreamDataRepository, private val streamsSettingsDataStore: StreamsSettingsDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, @@ -277,19 +275,19 @@ class ChatInputViewModel( helperText, codePointCount, chatSettingsDataStore.userLongClickBehavior, - ) { (text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput), (sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget, isAnnouncing), helperText, codePoints, userLongClickBehavior -> - val isMentionsTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 0 - val isWhisperTabActive = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 1 - val isInReplyThread = sheetState is FullScreenSheetState.Replies - val effectiveIsReplying = isReplying || isInReplyThread - val canTypeInConnectionState = connectionState == ConnectionState.CONNECTED || !autoDisableInput - - val inputState = when (connectionState) { + ) { 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.autoDisableInput + + val inputState = when (deps.connectionState) { ConnectionState.CONNECTED -> when { - isWhisperTabActive && whisperTarget != null -> InputState.Whispering - effectiveIsReplying -> InputState.Replying - isAnnouncing -> InputState.Announcing - else -> InputState.Default + isWhisperTabActive && overlayState.whisperTarget != null -> InputState.Whispering + effectiveIsReplying -> InputState.Replying + overlayState.isAnnouncing -> InputState.Announcing + else -> InputState.Default } ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn @@ -298,36 +296,36 @@ class ChatInputViewModel( val enabled = when { isMentionsTabActive -> false - isWhisperTabActive -> isLoggedIn && canTypeInConnectionState && whisperTarget != null - else -> isLoggedIn && canTypeInConnectionState + isWhisperTabActive -> deps.isLoggedIn && canTypeInConnectionState && overlayState.whisperTarget != null + else -> deps.isLoggedIn && canTypeInConnectionState } - val canSend = text.isNotBlank() && activeChannel != null && connectionState == ConnectionState.CONNECTED && isLoggedIn && enabled + val canSend = deps.text.isNotBlank() && deps.activeChannel != null && deps.connectionState == ConnectionState.CONNECTED && deps.isLoggedIn && enabled - val effectiveReplyName = replyName ?: (sheetState as? FullScreenSheetState.Replies)?.replyName + val effectiveReplyName = overlayState.replyName ?: (overlayState.sheetState as? FullScreenSheetState.Replies)?.replyName val overlay = when { - isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName) - isWhisperTabActive && whisperTarget != null -> InputOverlay.Whisper(whisperTarget) - isAnnouncing -> InputOverlay.Announce - else -> InputOverlay.None + overlayState.isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName) + isWhisperTabActive && overlayState.whisperTarget != null -> InputOverlay.Whisper(overlayState.whisperTarget) + overlayState.isAnnouncing -> InputOverlay.Announce + else -> InputOverlay.None } ChatInputUiState( - text = text, + text = deps.text, canSend = canSend, enabled = enabled, hasLastMessage = when { isWhisperTabActive -> lastWhisperText != null else -> chatRepository.getLastMessage() != null }, - suggestions = suggestions.toImmutableList(), - activeChannel = activeChannel, - connectionState = connectionState, - isLoggedIn = isLoggedIn, + suggestions = deps.suggestions.toImmutableList(), + activeChannel = deps.activeChannel, + connectionState = deps.connectionState, + isLoggedIn = deps.isLoggedIn, inputState = inputState, overlay = overlay, - replyMessageId = replyMessageId ?: (sheetState as? FullScreenSheetState.Replies)?.replyMessageId, - isEmoteMenuOpen = isEmoteMenuOpen, + replyMessageId = overlayState.replyMessageId ?: (overlayState.sheetState as? FullScreenSheetState.Replies)?.replyMessageId, + isEmoteMenuOpen = overlayState.isEmoteMenuOpen, helperText = helperText, isWhisperTabActive = isWhisperTabActive, characterCounter = CharacterCounterState.Visible( 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 index 0f806a956..c87071a1b 100644 --- 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 @@ -25,18 +25,18 @@ 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, - isLoggedIn: Boolean, mentionViewModel: MentionViewModel, onDismiss: () -> Unit, onDismissReplies: () -> Unit, onUserClick: (UserPopupStateParams) -> Unit, onMessageLongClick: (MessageOptionsParams) -> Unit, onEmoteClick: (List) -> Unit, - userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, modifier: Modifier = Modifier, + userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, onWhisperReply: (UserName) -> Unit = {}, onUserMention: (UserName, DisplayName) -> Unit = { _, _ -> }, bottomContentPadding: Dp = 0.dp, 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 index f3ce77348..de3b58b00 100644 --- 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 @@ -104,7 +104,7 @@ fun MentionSheet( backProgress = event.progress } onDismiss() - } catch (e: CancellationException) { + } catch (_: CancellationException) { backProgress = 0f } } @@ -125,6 +125,7 @@ fun MentionSheet( state = pagerState, modifier = Modifier.fillMaxSize(), ) { page -> + @Suppress("ViewModelForwarding") MentionComposable( mentionViewModel = mentionViewModel, isWhisperTab = page == 1, 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 index c6225cfa6..72c5c5350 100644 --- 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 @@ -120,7 +120,7 @@ fun MessageHistorySheet( backProgress = event.progress } onDismiss() - } catch (e: CancellationException) { + } catch (_: CancellationException) { backProgress = 0f } } 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 index 482c7c882..dc8fa7c16 100644 --- 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 @@ -88,7 +88,7 @@ fun RepliesSheet( backProgress = event.progress } onDismiss() - } catch (e: CancellationException) { + } catch (_: CancellationException) { backProgress = 0f } } @@ -109,7 +109,7 @@ fun RepliesSheet( repliesViewModel = viewModel, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, - onNotFound = onDismiss, + onMissing = onDismiss, containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), scrollModifier = scrollModifier, 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 index 78ef6a9b9..878eeeb87 100644 --- 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 @@ -35,6 +35,7 @@ import androidx.core.view.doOnAttach import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName +@Suppress("LambdaParameterEventTrailing") @Composable fun StreamView( channel: UserName, 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 index 96c8178f3..be201b8ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt @@ -111,7 +111,7 @@ fun OnboardingScreen( ) { page -> when (page) { 0 -> WelcomePage( - onGetStarted = { scope.launch { pagerState.animateScrollToPage(1) } }, + onStart = { scope.launch { pagerState.animateScrollToPage(1) } }, ) 1 -> LoginPage( @@ -147,9 +147,9 @@ fun OnboardingScreen( @Composable private fun OnboardingPage( title: String, - modifier: Modifier = Modifier, icon: @Composable () -> Unit, body: @Composable () -> Unit, + modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { Column( @@ -185,7 +185,7 @@ private fun OnboardingBody(text: String) { @Composable private fun WelcomePage( - onGetStarted: () -> Unit, + onStart: () -> Unit, modifier: Modifier = Modifier, ) { OnboardingPage( @@ -201,7 +201,7 @@ private fun WelcomePage( body = { OnboardingBody(stringResource(R.string.onboarding_welcome_body)) }, modifier = modifier, ) { - Button(onClick = onGetStarted) { + Button(onClick = onStart) { Text(stringResource(R.string.onboarding_get_started)) } } 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 index eb55536c2..9beaf1bf1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt @@ -101,7 +101,7 @@ class ShareUploadActivity : ComponentActivity() { copy.removeExifAttributes() } copy - } catch (e: Throwable) { + } catch (_: Throwable) { null } } 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 index ff3803e62..70ec53573 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt @@ -19,11 +19,11 @@ import com.flxrs.dankchat.R @Composable fun ConfirmationBottomSheet( title: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, message: String? = null, confirmText: String = stringResource(R.string.dialog_ok), dismissText: String = stringResource(R.string.dialog_cancel), - onConfirm: () -> Unit, - onDismiss: () -> Unit, ) { StyledBottomSheet(onDismiss = onDismiss) { Text( 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..f5ba7a2ee 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,7 +37,7 @@ object ContentAlpha { val high: Float @Composable get() = - contentAlpha( + resolveAlpha( highContrastAlpha = HighContrastContentAlpha.high, lowContrastAlpha = LowContrastContentAlpha.high ) @@ -49,7 +49,7 @@ object ContentAlpha { val medium: Float @Composable get() = - contentAlpha( + resolveAlpha( highContrastAlpha = HighContrastContentAlpha.medium, lowContrastAlpha = LowContrastContentAlpha.medium ) @@ -61,7 +61,7 @@ object ContentAlpha { val disabled: Float @Composable get() = - contentAlpha( + resolveAlpha( highContrastAlpha = HighContrastContentAlpha.disabled, lowContrastAlpha = LowContrastContentAlpha.disabled ) @@ -75,7 +75,7 @@ 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 ): Float { 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 index e18ae6070..f3054f18f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt @@ -35,10 +35,10 @@ fun InfoBottomSheet( title: String, message: String, confirmText: String, - dismissText: String = stringResource(R.string.dialog_dismiss), - dismissible: Boolean = true, onConfirm: () -> Unit, onDismiss: () -> Unit, + dismissText: String = stringResource(R.string.dialog_dismiss), + dismissible: Boolean = true, ) { when { dismissible -> { 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 index 62fa7a2aa..1ad6efe94 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt @@ -39,13 +39,13 @@ import com.flxrs.dankchat.R 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, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit, ) { var inputValue by remember { mutableStateOf(TextFieldValue(defaultValue, selection = TextRange(defaultValue.length))) } val focusRequester = remember { FocusRequester() } 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 index 0438f9d17..2b29629b6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -35,6 +35,7 @@ import kotlin.math.sin * * 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) 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/StringExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/StringExtensions.kt index 7bf654b2c..0acb8cded 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() 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 3ece4e7d6..7974828d7 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,6 +3,7 @@ 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 From c9f539477abab07995a4eaa7661b1dadb617d36a Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 20:44:19 +0200 Subject: [PATCH 162/349] refactor: Migrate to Kotlin AtomicInt, localize ChatMessageSender error strings across 23 locales --- app/build.gradle.kts | 1 + .../dankchat/data/api/helix/HelixApiStats.kt | 15 ++++--- .../data/notification/NotificationService.kt | 44 +++++++++---------- .../data/repo/chat/ChatChannelProvider.kt | 2 +- .../dankchat/data/repo/chat/ChatConnector.kt | 4 -- .../data/repo/chat/ChatMessageRepository.kt | 33 +++++++------- .../data/repo/chat/ChatMessageSender.kt | 35 +++++++-------- .../dankchat/data/repo/chat/ChatRepository.kt | 9 +--- .../data/repo/stream/StreamDataRepository.kt | 8 ++-- .../data/twitch/message/SystemMessageType.kt | 9 ++++ .../dankchat/ui/chat/ChatMessageMapper.kt | 9 ++++ .../main/res/values-b+zh+Hant+TW/strings.xml | 9 ++++ app/src/main/res/values-be-rBY/strings.xml | 9 ++++ app/src/main/res/values-ca/strings.xml | 9 ++++ app/src/main/res/values-cs/strings.xml | 9 ++++ app/src/main/res/values-de-rDE/strings.xml | 9 ++++ app/src/main/res/values-en-rAU/strings.xml | 9 ++++ app/src/main/res/values-en-rGB/strings.xml | 9 ++++ app/src/main/res/values-en/strings.xml | 9 ++++ app/src/main/res/values-es-rES/strings.xml | 9 ++++ app/src/main/res/values-fi-rFI/strings.xml | 9 ++++ app/src/main/res/values-fr-rFR/strings.xml | 9 ++++ app/src/main/res/values-hu-rHU/strings.xml | 9 ++++ app/src/main/res/values-it/strings.xml | 9 ++++ app/src/main/res/values-ja-rJP/strings.xml | 9 ++++ app/src/main/res/values-kk-rKZ/strings.xml | 9 ++++ app/src/main/res/values-or-rIN/strings.xml | 9 ++++ app/src/main/res/values-pl-rPL/strings.xml | 9 ++++ app/src/main/res/values-pt-rBR/strings.xml | 9 ++++ app/src/main/res/values-pt-rPT/strings.xml | 9 ++++ app/src/main/res/values-ru-rRU/strings.xml | 9 ++++ app/src/main/res/values-sr/strings.xml | 9 ++++ app/src/main/res/values-tr-rTR/strings.xml | 9 ++++ app/src/main/res/values-uk-rUA/strings.xml | 9 ++++ app/src/main/res/values/strings.xml | 11 +++++ 35 files changed, 309 insertions(+), 78 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d6b96d9c6..94499a795 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -119,6 +119,7 @@ kotlin { "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", "-opt-in=kotlin.uuid.ExperimentalUuidApi", "-opt-in=kotlin.time.ExperimentalTime", + "-opt-in=kotlin.concurrent.atomics.ExperimentalAtomicApi", "-Xnon-local-break-continue", "-Xwhen-guards", ) 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 index 11f713707..dd4e7ca78 100644 --- 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 @@ -2,18 +2,19 @@ package com.flxrs.dankchat.data.api.helix import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.plusAssign @Single class HelixApiStats { - private val _totalRequests = AtomicInteger(0) - private val _statusCounts = ConcurrentHashMap() + private val _totalRequests = AtomicInt(0) + private val _statusCounts = ConcurrentHashMap() - val totalRequests: Int get() = _totalRequests.get() - val statusCounts: Map get() = _statusCounts.mapValues { it.value.get() } + val totalRequests: Int get() = _totalRequests.load() + val statusCounts: Map get() = _statusCounts.mapValues { it.value.load() } fun recordResponse(statusCode: Int) { - _totalRequests.incrementAndGet() - _statusCounts.getOrPut(statusCode) { AtomicInteger(0) }.incrementAndGet() + _totalRequests += 1 + _statusCounts.getOrPut(statusCode) { AtomicInt(0) } += 1 } } 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 5f39deeb7..4452b3c2c 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 @@ -39,7 +39,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import java.util.Locale -import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.atomics.AtomicInt import kotlin.coroutines.CoroutineContext class NotificationService : Service(), CoroutineScope { @@ -117,7 +117,7 @@ class NotificationService : Service(), CoroutineScope { 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 @@ -145,7 +145,7 @@ class NotificationService : Service(), CoroutineScope { private suspend fun setTTSEnabled(enabled: Boolean) = when { enabled -> initTTS() - else -> shutdownTTS() + else -> shutdownTTS() } private suspend fun initTTS() { @@ -154,7 +154,7 @@ class NotificationService : Service(), CoroutineScope { tts = TextToSpeech(this) { status -> when (status) { TextToSpeech.SUCCESS -> setTTSVoice(forceEnglish = forceEnglish) - else -> shutdownAndDisableTTS() + else -> shutdownAndDisableTTS() } } } @@ -162,7 +162,7 @@ class NotificationService : Service(), CoroutineScope { private fun setTTSVoice(forceEnglish: Boolean) { val voice = when { forceEnglish -> tts?.voices?.find { it.locale == Locale.US && !it.isNetworkConnectionRequired } - else -> tts?.defaultVoice + else -> tts?.defaultVoice } voice?.takeUnless { tts?.setVoice(it) == TextToSpeech.ERROR } ?: shutdownAndDisableTTS() @@ -235,10 +235,10 @@ class NotificationService : Service(), CoroutineScope { } val channel = when (message) { - is PrivMessage -> message.channel + is PrivMessage -> message.channel is UserNoticeMessage -> message.channel - is NoticeMessage -> message.channel - else -> return@forEach + is NoticeMessage -> message.channel + else -> return@forEach } if (!toolSettings.ttsEnabled || channel != activeTTSChannel || (audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { @@ -264,8 +264,8 @@ class NotificationService : Service(), CoroutineScope { private fun Message.playTTSMessage() { val message = when (this) { is UserNoticeMessage -> message - is NoticeMessage -> message - else -> { + is NoticeMessage -> message + else -> { if (this !is PrivMessage) return val filtered = message .filterEmotes(emotes) @@ -278,14 +278,14 @@ class NotificationService : Service(), CoroutineScope { when { toolSettings.ttsMessageFormat == TTSMessageFormat.Message || name == previousTTSUser -> filtered - tts?.voice?.locale?.language == Locale.ENGLISH.language -> "$name said $filtered" - else -> "$name. $filtered" + tts?.voice?.locale?.language == Locale.ENGLISH.language -> "$name said $filtered" + else -> "$name. $filtered" }.also { previousTTSUser = name } } } val queueMode = when (toolSettings.ttsPlayMode) { - TTSPlayMode.Queue -> TextToSpeech.QUEUE_ADD + TTSPlayMode.Queue -> TextToSpeech.QUEUE_ADD TTSPlayMode.Newest -> TextToSpeech.QUEUE_FLUSH } tts?.speak(message, queueMode, null, null) @@ -296,25 +296,25 @@ class NotificationService : Service(), CoroutineScope { acc.replace(emote.code, newValue = "", ignoreCase = true) } - else -> this + 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 + else -> this } private fun String.filterUrls(): String = when { toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") - else -> this + else -> this } 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.getAndIncrement(), it, pendingIntentFlag) + PendingIntent.getActivity(this@NotificationService, notificationIntentCode.fetchAndAdd(1), it, pendingIntentFlag) } val summary = NotificationCompat.Builder(this@NotificationService, CHANNEL_ID_DEFAULT) @@ -328,8 +328,8 @@ class NotificationService : Service(), CoroutineScope { 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) + isNotify -> getString(R.string.notification_notify_mention, channel) + else -> getString(R.string.notification_mention, name, channel) } val notification = NotificationCompat.Builder(this@NotificationService, CHANNEL_ID_DEFAULT) @@ -340,7 +340,7 @@ class NotificationService : Service(), CoroutineScope { .setGroup(MENTION_GROUP) .build() - val id = notificationId.getAndIncrement() + val id = notificationId.fetchAndAdd(1) notifications.getOrPut(channel) { mutableListOf() } += id manager.notify(id, notification) @@ -364,7 +364,7 @@ class NotificationService : Service(), CoroutineScope { private const val MAX_NOTIFIED_IDS = 500 - private val notificationId = AtomicInteger(42) - private val notificationIntentCode = AtomicInteger(420) + private val notificationId = AtomicInt(42) + private val notificationIntentCode = AtomicInt(420) } } 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 index 25009cffc..314ca9cd9 100644 --- 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 @@ -13,7 +13,7 @@ import org.koin.core.annotation.Single class ChatChannelProvider(preferenceStore: DankChatPreferenceStore) { private val _activeChannel = MutableStateFlow(null) - private val _channels = MutableStateFlow?>(preferenceStore.channels.takeIf { it.isNotEmpty() }?.toImmutableList()) + private val _channels = MutableStateFlow(preferenceStore.channels.takeIf { it.isNotEmpty() }?.toImmutableList()) val activeChannel: StateFlow = _activeChannel.asStateFlow() val channels: StateFlow?> = _channels.asStateFlow() 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 index 6f63d7e94..cd183a355 100644 --- 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 @@ -37,10 +37,6 @@ class ChatConnector( fun getConnectionState(channel: UserName): StateFlow = connectionState.getOrPut(channel) { MutableStateFlow(ConnectionState.DISCONNECTED) } - fun setConnectionState(channel: UserName, state: ConnectionState) { - connectionState.getOrPut(channel) { MutableStateFlow(state) }.value = state - } - fun setAllConnectionStates(state: ConnectionState) { connectionState.forEach { (_, flow) -> flow.value = state 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 index 2c9c27b76..e6170ffc8 100644 --- 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 @@ -28,6 +28,8 @@ 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( @@ -40,25 +42,25 @@ class ChatMessageRepository( private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val messages = ConcurrentHashMap>>() private val _chatLoadingFailures = MutableStateFlow(emptySet()) - private val _sessionMessageCount = java.util.concurrent.atomic.AtomicInteger(0) - private val _ircSentCount = java.util.concurrent.atomic.AtomicInteger(0) - private val _helixSentCount = java.util.concurrent.atomic.AtomicInteger(0) - private val _sendFailureCount = java.util.concurrent.atomic.AtomicInteger(0) + 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.get() - val ircSentCount: Int get() = _ircSentCount.get() - val helixSentCount: Int get() = _helixSentCount.get() - val sendFailureCount: Int get() = _sendFailureCount.get() + 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.incrementAndGet() - ChatSendProtocol.Helix -> _helixSentCount.incrementAndGet() + ChatSendProtocol.IRC -> _ircSentCount += 1 + ChatSendProtocol.Helix -> _helixSentCount += 1 } } fun incrementSendFailureCount() { - _sendFailureCount.incrementAndGet() + _sendFailureCount += 1 } private val scrollBackLengthFlow = chatSettingsDataStore.debouncedScrollBack @@ -82,7 +84,7 @@ class ChatMessageRepository( (channel?.let { messages[it] } ?: whispers).value.find { it.message.id == messageId }?.message fun addMessages(channel: UserName, items: List) { - _sessionMessageCount.addAndGet(items.size) + _sessionMessageCount += items.size messages[channel]?.update { current -> current.addAndLimit(items = items, scrollBackLength, messageProcessor::onMessageRemoved) } @@ -92,9 +94,10 @@ class ChatMessageRepository( messages[message.channel]?.update { current -> when (message.action) { ModerationMessage.Action.Delete, - ModerationMessage.Action.SharedDelete -> current.replaceWithTimeout(message, scrollBackLength, messageProcessor::onMessageRemoved) + ModerationMessage.Action.SharedDelete, + -> current.replaceWithTimeout(message, scrollBackLength, messageProcessor::onMessageRemoved) - else -> current.replaceOrAddModerationMessage(message, scrollBackLength, messageProcessor::onMessageRemoved) + else -> current.replaceOrAddModerationMessage(message, scrollBackLength, messageProcessor::onMessageRemoved) } } } @@ -119,7 +122,7 @@ class ChatMessageRepository( msg is AutomodMessage && msg.heldMessageId == heldMessageId -> item.copy(tag = item.tag + 1, message = msg.copy(status = status)) - else -> item + else -> item } } } 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 index 622ce174f..9bbb61b26 100644 --- 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 @@ -55,11 +55,11 @@ class ChatMessageSender( private suspend fun sendViaHelix(channel: UserName, message: String, replyId: String?) { val trimmedMessage = message.trimEnd() val senderId = authDataStore.userIdString ?: run { - postError(channel, "Not logged in.") + postError(channel, SystemMessageType.SendNotLoggedIn) return } val broadcasterId = channelRepository.getChannel(channel)?.id ?: run { - postError(channel, "Could not resolve channel ID for $channel.") + postError(channel, SystemMessageType.SendChannelNotResolved(channel)) return } @@ -79,23 +79,23 @@ class ChatMessageSender( } else -> { - val msg = when (val reason = response.dropReason) { - null -> "Message was not sent." - else -> "Message dropped: ${reason.message} (${reason.code})" + val type = when (val reason = response.dropReason) { + null -> SystemMessageType.SendNotDelivered + else -> SystemMessageType.SendDropped(reason.message, reason.code) } - postError(channel, msg) + postError(channel, type) } } }, onFailure = { throwable -> Log.e(TAG, "Helix send failed", throwable) - postError(channel, throwable.toSendErrorMessage()) + postError(channel, throwable.toSendErrorType()) }, ) } - private fun postError(channel: UserName, message: String) { - chatMessageRepository.addSystemMessage(channel, SystemMessageType.Custom(message)) + private fun postError(channel: UserName, type: SystemMessageType) { + chatMessageRepository.addSystemMessage(channel, type) chatMessageRepository.incrementSendFailureCount() } @@ -112,18 +112,17 @@ class ChatMessageSender( } } - private fun Throwable.toSendErrorMessage(): String = when (this) { + private fun Throwable.toSendErrorType(): SystemMessageType = when (this) { is HelixApiException -> when (error) { - HelixError.NotLoggedIn -> "Not logged in." - HelixError.MissingScopes -> "Missing user:write:chat scope. Please re-login." - HelixError.UserNotAuthorized -> "Not authorized to send messages in this channel." - HelixError.MessageTooLarge -> "Message is too large." - HelixError.ChatMessageRateLimited -> "Rate limited. Try again in a moment." - HelixError.Forwarded -> message ?: "Unknown error." - else -> message ?: "Unknown 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 -> message ?: "Unknown error." + else -> SystemMessageType.SendFailed(message) } companion object { 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 ae9cf5f24..71fe8ee6d 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 @@ -41,8 +41,6 @@ class ChatRepository( chatChannelProvider.channels.value?.forEach { createFlowsIfNecessary(it) } } - fun setActiveChannel(channel: UserName?) = chatChannelProvider.setActiveChannel(channel) - fun joinChannel(channel: UserName): List { val currentChannels = channels.value.orEmpty() if (channel in currentChannels) { @@ -121,7 +119,7 @@ class ChatRepository( suspend fun loadRecentMessagesIfEnabled(channel: UserName) { when { chatSettingsDataStore.settings.first().loadMessageHistory -> chatEventProcessor.loadRecentMessages(channel) - else -> { + else -> { chatMessageRepository.getMessagesFlow(channel)?.update { current -> current + SystemMessageType.NoHistoryLoaded.toChatItem() } @@ -129,10 +127,6 @@ class ChatRepository( } } - fun makeAndPostSystemMessage(type: SystemMessageType, channel: UserName) { - chatMessageRepository.addSystemMessage(channel, type) - } - fun makeAndPostCustomSystemMessage(msg: String, channel: UserName) { chatMessageRepository.addSystemMessage(channel, SystemMessageType.Custom(msg)) } @@ -149,5 +143,4 @@ class ChatRepository( emoteRepository.removeChannel(channel) messageProcessor.cleanupMessageThreadsInChannel(channel) } - } 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 index 707dd6cca..b69aa0715 100644 --- 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 @@ -20,6 +20,8 @@ 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 @@ -34,8 +36,8 @@ class StreamDataRepository( private var fetchTimerJob: Job? = null private val _streamData = MutableStateFlow>(persistentListOf()) val streamData: StateFlow> = _streamData.asStateFlow() - private val _fetchCount = java.util.concurrent.atomic.AtomicInteger(0) - val fetchCount: Int get() = _fetchCount.get() + private val _fetchCount = AtomicInt(0) + val fetchCount: Int get() = _fetchCount.load() fun fetchStreamData(channels: List) { cancelStreamData() @@ -55,7 +57,7 @@ class StreamDataRepository( suspend fun fetchOnce(channels: List) { val currentSettings = streamsSettingsDataStore.settings.first() - _fetchCount.incrementAndGet() + _fetchCount += 1 val data = dataRepository.getStreams(channels)?.map { val uptime = DateTimeUtils.calculateUptime(it.startedAt) val category = it.category 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 b8f1e8bb6..641959114 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 @@ -23,6 +23,15 @@ sealed interface 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 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 } 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 index 6c49d3cd9..909d6a4c9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -140,6 +140,15 @@ class ChatMessageMapper( is SystemMessageType.ChannelSevenTVEmotesFailed -> TextResource.Res(R.string.system_message_7tv_emotes_failed, persistentListOf(type.status)) is SystemMessageType.Custom -> TextResource.Plain(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 ?: "")) 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)) 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 1a914f110..eafa54320 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 @@ -656,4 +656,13 @@ 將 EventSub 相關除錯資訊以系統訊息顯示 撤銷權杖並重新啟動 使目前的權杖失效並重新啟動應用程式 + 未登入 + 無法解析 %1$s 的頻道 ID + 訊息未送出 + 訊息被丟棄:%1$s(%2$s) + 缺少 user:write:chat 權限,請重新登入 + 無權在此頻道發送訊息 + 訊息過長 + 已被限速,請稍後再試 + 發送失敗:%1$s diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index bc1af2c1e..f6f24b7ad 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -673,4 +673,13 @@ Выводзіць адладачную інфармацыю пра EventSub як сістэмныя паведамленні Адклікаць токен і перазапусціць Робіць бягучы токен несапраўдным і перазапускае праграму + Не ўвайшлі ў сістэму + Не ўдалося вызначыць ID канала для %1$s + Паведамленне не было адпраўлена + Паведамленне адхілена: %1$s (%2$s) + Адсутнічае дазвол user:write:chat, калі ласка, увайдзіце зноў + Няма дазволу адпраўляць паведамленні ў гэтым канале + Паведамленне занадта вялікае + Перавышаны ліміт запытаў, паспрабуйце пазней + Памылка адпраўкі: %1$s diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index de0ec9c2b..5c6e0499f 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -699,4 +699,13 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 680dcea6a..fd1b57361 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -674,4 +674,13 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index fa53daac3..0aab3bc8a 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -672,4 +672,13 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 8a60b9a54..d60a645d3 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -651,4 +651,13 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index e65c648e5..a3f26ff89 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -651,4 +651,13 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 121ed29af..7c54dc08f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -666,4 +666,13 @@ 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index afaf3dc87..4d24320e9 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -682,4 +682,13 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 1e50eb157..2602efe78 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -674,4 +674,13 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 08d49fd09..82629056e 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -666,4 +666,13 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 0ade61ab4..af407fc19 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -650,4 +650,13 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8c6653d02..3850c8415 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -649,4 +649,13 @@ 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index b08c071bc..70804fcab 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -630,4 +630,13 @@ EventSub関連のデバッグ情報をシステムメッセージとして表示します トークンを失効させて再起動 現在のトークンを無効化してアプリを再起動します + ログインしていません + %1$s のチャンネルIDを解決できませんでした + メッセージは送信されませんでした + メッセージが破棄されました: %1$s (%2$s) + user:write:chat スコープがありません。再ログインしてください + このチャンネルでメッセージを送信する権限がありません + メッセージが大きすぎます + レート制限中です。しばらくしてから再試行してください + 送信失敗: %1$s diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 30b4c2e21..404642bdc 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -655,4 +655,13 @@ EventSubқа қатысты жөндеу ақпаратын жүйелік хабарлар ретінде көрсетеді Токенді қайтарып алу және қайта іске қосу Ағымдағы токенді жарамсыз етіп, қолданбаны қайта іске қосады + Жүйеге кірілмеген + %1$s үшін арна ID анықталмады + Хабарлама жіберілмеді + Хабарлама тасталды: %1$s (%2$s) + user:write:chat рұқсаты жоқ, қайта кіріңіз + Бұл арнада хабарлама жіберуге рұқсат жоқ + Хабарлама тым үлкен + Жылдамдық шектелді, біраз уақыттан кейін қайталаңыз + Жіберу сәтсіз: %1$s diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 21f8e4c3e..da677dd31 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -655,4 +655,13 @@ EventSub ସମ୍ବନ୍ଧୀୟ ଡିବଗ୍ ତଥ୍ୟ ସିଷ୍ଟମ ମେସେଜ୍ ଭାବରେ ଦେଖାଏ ଟୋକେନ୍ ବାତିଲ କରନ୍ତୁ ଏବଂ ପୁନଃଆରମ୍ଭ କରନ୍ତୁ ବର୍ତ୍ତମାନର ଟୋକେନ୍ ଅବୈଧ କରି ଆପ୍ ପୁନଃଆରମ୍ଭ କରେ + ଲଗ୍ ଇନ୍ ହୋଇନାହାଁନ୍ତି + %1$s ପାଇଁ ଚ୍ୟାନେଲ ID ସମାଧାନ ହୋଇପାରିଲା ନାହିଁ + ମେସେଜ୍ ପଠାଯାଇନାହିଁ + ମେସେଜ୍ ବାଦ୍ ପଡ଼ିଲା: %1$s (%2$s) + user:write:chat ଅନୁମତି ନାହିଁ, ଦୟାକରି ପୁନଃ ଲଗ୍ ଇନ୍ କରନ୍ତୁ + ଏହି ଚ୍ୟାନେଲରେ ମେସେଜ୍ ପଠାଇବାକୁ ଅନୁମତି ନାହିଁ + ମେସେଜ୍ ବହୁତ ବଡ଼ + ହାର ସୀମିତ, କିଛି ସମୟ ପରେ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ + ପଠାଇବା ବିଫଳ: %1$s diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 558d80402..39f3e8aa3 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -692,4 +692,13 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3b27d46b2..d2eba5263 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -661,4 +661,13 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 37705a24f..c8d40288e 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -651,4 +651,13 @@ 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index f5cfb9920..6ffcc748c 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -678,4 +678,13 @@ Выводит отладочную информацию по EventSub в виде системных сообщений Отозвать токен и перезапустить Аннулирует текущий токен и перезапускает приложение + Не выполнен вход + Не удалось определить ID канала для %1$s + Сообщение не было отправлено + Сообщение отклонено: %1$s (%2$s) + Отсутствует разрешение user:write:chat, войдите заново + Нет прав для отправки сообщений в этом канале + Сообщение слишком большое + Превышен лимит запросов, попробуйте через некоторое время + Ошибка отправки: %1$s diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 7a054a12a..a10996337 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -703,4 +703,13 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Приказује дебаг излаз везан за EventSub као системске поруке Опозови токен и рестартуј Поништава тренутни токен и рестартује апликацију + Нисте пријављени + Није могуће одредити ID канала за %1$s + Порука није послата + Порука одбачена: %1$s (%2$s) + Недостаје дозвола user:write:chat, пријавите се поново + Немате дозволу за слање порука у овом каналу + Порука је превелика + Ограничење брзине, покушајте поново за тренутак + Слање неуспешно: %1$s diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 0b1ed1aaf..22c5bd106 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -671,4 +671,13 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 5774aee6e..785a8a6b1 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -675,4 +675,13 @@ Виводить налагоджувальну інформацію щодо EventSub як системні повідомлення Відкликати токен і перезапустити Анулює поточний токен і перезапускає застосунок + Не виконано вхід + Не вдалося визначити ID каналу для %1$s + Повідомлення не було надіслано + Повідомлення відхилено: %1$s (%2$s) + Відсутній дозвіл user:write:chat, будь ласка, увійдіть знову + Немає дозволу надсилати повідомлення в цьому каналі + Повідомлення занадто велике + Перевищено ліміт запитів, спробуйте через деякий час + Помилка надсилання: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e10a83f17..9db28b0f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -134,6 +134,17 @@ %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 From 4cce56b63fc29329d685b5a3235e36e8877e94d4 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 20:53:41 +0200 Subject: [PATCH 163/349] fix: Clean up WebSocket and EventSub code, remove unused hasModeratorTopic --- .../data/api/eventapi/EventSubClient.kt | 27 +++++++++---------- .../data/api/eventapi/EventSubManager.kt | 16 +++++------ .../data/twitch/pubsub/PubSubConnection.kt | 27 ++++++++----------- 3 files changed, 31 insertions(+), 39 deletions(-) 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 f5237f2c8..fb6a4da6e 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 @@ -98,11 +98,8 @@ class EventSubClient( 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 cause = result.exceptionOrNull() ?: // websocket likely received a close frame, no need to reconnect + return@webSocket // rethrow to trigger reconnect logic throw cause @@ -111,7 +108,7 @@ class EventSubClient( else -> (element as? Frame.Text)?.readText() ?: continue } - //Log.v(TAG, "[EventSub] Received raw message: $raw") + // Log.v(TAG, "[EventSub] Received raw message: $raw") val jsonObject = json .parseToJsonElement(raw) @@ -126,7 +123,7 @@ class EventSubClient( } 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}") @@ -149,10 +146,10 @@ class EventSubClient( } } - is ReconnectMessageDto -> handleReconnect(message) - is RevocationMessageDto -> handleRevocation(message) + is ReconnectMessageDto -> handleReconnect(message) + is RevocationMessageDto -> handleRevocation(message) is NotificationMessageDto -> handleNotification(message) - is KeepAliveMessageDto -> Unit + is KeepAliveMessageDto -> Unit } } } @@ -264,28 +261,28 @@ class EventSubClient( private fun handleNotification(message: NotificationMessageDto) { Log.d(TAG, "[EventSub] received notification message: $message") val eventSubMessage = when (val event = message.payload.event) { - is ChannelModerateDto -> ModerationAction( + is ChannelModerateDto -> ModerationAction( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is AutomodMessageHoldDto -> AutomodHeld( + is AutomodMessageHoldDto -> AutomodHeld( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is AutomodMessageUpdateDto -> AutomodUpdate( + is AutomodMessageUpdateDto -> AutomodUpdate( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is ChannelChatUserMessageHoldDto -> UserMessageHeld( + is ChannelChatUserMessageHoldDto -> UserMessageHeld( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, @@ -340,7 +337,7 @@ class EventSubClient( return true } - else -> { + else -> { subscriptions.update { emptySet() } EventSubClientState.Disconnected } 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 d30f3ac9f..e312c7e94 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 @@ -41,10 +41,10 @@ class EventSubManager( userStateRepository.userState.map { it.moderationChannels }.collect { val userId = authDataStore.userIdString ?: return@collect val channels = channelRepository.getChannels(it) - channels.forEach { - eventSubClient.subscribe(EventSubTopic.ChannelModerate(channel = it.name, broadcasterId = it.id, moderatorId = userId)) - eventSubClient.subscribe(EventSubTopic.AutomodMessageHold(channel = it.name, broadcasterId = it.id, moderatorId = userId)) - eventSubClient.subscribe(EventSubTopic.AutomodMessageUpdate(channel = it.name, broadcasterId = it.id, moderatorId = userId)) + 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)) } } } @@ -96,11 +96,11 @@ class EventSubManager( scope.launch { 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.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 + 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/twitch/pubsub/PubSubConnection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubConnection.kt index 5cd352242..6e5020c5a 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,7 +1,6 @@ package com.flxrs.dankchat.data.twitch.pubsub import android.util.Log -import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.ifBlank import com.flxrs.dankchat.data.toUserId @@ -76,10 +75,6 @@ class PubSubConnection( 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 } @@ -260,20 +255,20 @@ class PubSubConnection( val json = JSONObject(text) val type = json.optString("type").ifBlank { return false } when (type) { - "PONG" -> awaitingPong = false + "PONG" -> awaitingPong = false "RECONNECT" -> { Log.i(TAG, "[PubSub $tag] server requested reconnect") return true } - "RESPONSE" -> { + "RESPONSE" -> { val error = json.optString("error") if (error.isNotBlank()) { Log.w(TAG, "[PubSub $tag] RESPONSE error: $error") } } - "MESSAGE" -> { + "MESSAGE" -> { val data = json.optJSONObject("data") ?: return false val topic = data.optString("topic").ifBlank { return false } val message = data.optString("message").ifBlank { return false } @@ -281,7 +276,7 @@ class PubSubConnection( val messageTopic = messageObject.optString("type") val match = topics.find { topic == it.topic } ?: return false val pubSubMessage = when (match) { - is PubSubTopic.Whispers -> { + is PubSubTopic.Whispers -> { if (messageTopic !in listOf("whisper_sent", "whisper_received")) { return false } @@ -300,13 +295,13 @@ class PubSubConnection( 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" -> { + "moderator_added" -> { val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false val timestamp = Clock.System.now() PubSubMessage.ModeratorAction( @@ -320,8 +315,8 @@ class PubSubConnection( creatorUserId = parsedMessage.data.creatorUserId, creator = parsedMessage.data.creator, createdAt = timestamp.toString(), - msgId = null - ) + msgId = null, + ), ) } @@ -332,7 +327,7 @@ class PubSubConnection( } val timestamp = when { parsedMessage.data.createdAt.isEmpty() -> Clock.System.now() - else -> Instant.parse(parsedMessage.data.createdAt) + else -> Instant.parse(parsedMessage.data.createdAt) } PubSubMessage.ModeratorAction( timestamp = timestamp, @@ -343,11 +338,11 @@ class PubSubConnection( creatorUserId = parsedMessage.data.creatorUserId?.ifBlank { null }, targetUserId = parsedMessage.data.targetUserId?.ifBlank { null }, targetUserName = parsedMessage.data.targetUserName?.ifBlank { null }, - ) + ), ) } - else -> return false + else -> return false } } } From 1c6f53e58a5bdeb33226fd2554f50b3751e3b8e3 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 21:02:47 +0200 Subject: [PATCH 164/349] fix: Clean up new feature data layer code, simplify EmoteRepository and RoomState --- .../data/api/eventapi/EventSubClient.kt | 20 ++++++++-------- .../data/api/eventapi/EventSubManager.kt | 8 +++---- .../data/debug/ConnectionDebugSection.kt | 15 ++++++------ .../data/repo/emote/EmoteRepository.kt | 23 +++++++------------ .../dankchat/data/twitch/message/RoomState.kt | 22 ++++++++---------- .../data/twitch/pubsub/PubSubConnection.kt | 23 ++++++++++--------- 6 files changed, 50 insertions(+), 61 deletions(-) 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 fb6a4da6e..d0d6ba311 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 @@ -108,7 +108,7 @@ class EventSubClient( else -> (element as? Frame.Text)?.readText() ?: continue } - // Log.v(TAG, "[EventSub] Received raw message: $raw") + //Log.v(TAG, "[EventSub] Received raw message: $raw") val jsonObject = json .parseToJsonElement(raw) @@ -123,7 +123,7 @@ class EventSubClient( } 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}") @@ -146,10 +146,10 @@ class EventSubClient( } } - is ReconnectMessageDto -> handleReconnect(message) - is RevocationMessageDto -> handleRevocation(message) + is ReconnectMessageDto -> handleReconnect(message) + is RevocationMessageDto -> handleRevocation(message) is NotificationMessageDto -> handleNotification(message) - is KeepAliveMessageDto -> Unit + is KeepAliveMessageDto -> Unit } } } @@ -261,28 +261,28 @@ class EventSubClient( private fun handleNotification(message: NotificationMessageDto) { Log.d(TAG, "[EventSub] received notification message: $message") val eventSubMessage = when (val event = message.payload.event) { - is ChannelModerateDto -> ModerationAction( + is ChannelModerateDto -> ModerationAction( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is AutomodMessageHoldDto -> AutomodHeld( + is AutomodMessageHoldDto -> AutomodHeld( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is AutomodMessageUpdateDto -> AutomodUpdate( + is AutomodMessageUpdateDto -> AutomodUpdate( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, data = event, ) - is ChannelChatUserMessageHoldDto -> UserMessageHeld( + is ChannelChatUserMessageHoldDto -> UserMessageHeld( id = message.metadata.messageId, timestamp = message.metadata.messageTimestamp, channelName = event.broadcasterUserLogin, @@ -337,7 +337,7 @@ class EventSubClient( return true } - else -> { + else -> { subscriptions.update { emptySet() } EventSubClientState.Disconnected } 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 e312c7e94..634def945 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 @@ -96,11 +96,11 @@ class EventSubManager( scope.launch { 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.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 + 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/debug/ConnectionDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt index d9d4ceb58..7d1f788d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt @@ -35,22 +35,21 @@ class ConnectionDebugSection( } 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.Connected -> "Connected (${state.sessionId.take(8)}...)" + is EventSubClientState.Connecting -> "Connecting" is EventSubClientState.Disconnected -> "Disconnected" - is EventSubClientState.Failed -> "Failed" + is EventSubClientState.Failed -> "Failed" } val pubSubStatus = when { - pubSubManager.connectedAndHasWhisperTopic -> "Connected (whispers)" - pubSubManager.connected -> "Connected" - else -> "Disconnected" + pubSubManager.connected -> "Connected" + else -> "Disconnected" } val sevenTvStatus = sevenTVEventApiClient.status() val sevenTvText = when { sevenTvStatus.connected -> "Connected (${sevenTvStatus.subscriptionCount} subs)" - else -> "Disconnected" + else -> "Disconnected" } val ircReadStatus = if (ircRead) "Connected" else "Disconnected" @@ -65,7 +64,7 @@ class ConnectionDebugSection( DebugEntry("EventSub", eventSubStatus), DebugEntry("EventSub topics", "${topics.size}"), DebugEntry("7TV EventAPI", sevenTvText), - ) + ), ) } } 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 8596014b0..7fdd71dfd 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 @@ -71,8 +71,8 @@ class EmoteRepository( private val chatSettingsDataStore: ChatSettingsDataStore, private val channelRepository: ChannelRepository, ) { - 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() @@ -82,9 +82,6 @@ class EmoteRepository( private val globalEmoteState = MutableStateFlow(GlobalEmoteState()) private val channelEmoteStates = ConcurrentHashMap>() - val badgeCache = LruCache(64) - val layerCache = LruCache(256) - fun getEmotes(channel: UserName): Flow { val channelFlow = channelEmoteStates.getOrPut(channel) { MutableStateFlow(ChannelEmoteState()) } return combine(globalEmoteState, channelFlow, ::mergeEmotes) @@ -127,7 +124,7 @@ class EmoteRepository( channelState.twitchEmotes.associateByTo(emoteMap) { it.code } } - // Single pass through words with O(1) lookups + // Single pass through words var currentPosition = 0 return buildList { message.split(WHITESPACE_REGEX).forEach { word -> @@ -464,12 +461,12 @@ class EmoteRepository( 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 } } @@ -690,7 +687,7 @@ 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) { @@ -866,8 +863,6 @@ class EmoteRepository( private val ESCAPE_TAG = 0x000E0002.codePointAsString val ESCAPE_TAG_REGEX = "(?.cacheKey(baseHeight: Int): String = joinToString(separator = "-") { it.id } + "-$baseHeight" private const val MAX_PARAMS_LENGTH = 2000 private val CHANNEL_EMOTE_TYPES = setOf("subscriptions", "bitstier", "follower") @@ -908,5 +903,3 @@ class EmoteRepository( ) } } - -private operator fun IntRange.inc() = first + 1..last + 1 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 d714de624..63eb3e45c 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 @@ -19,12 +19,8 @@ data class RoomState( 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 @@ -40,12 +36,12 @@ data class RoomState( .map { (tag, value) -> when (tag) { RoomStateTag.FOLLOW -> when (value) { - 0 -> "follow" + 0 -> "follow" else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" } - RoomStateTag.SLOW -> "slow(${DateTimeUtils.formatSeconds(value)})" - else -> tag.name.lowercase() + RoomStateTag.SLOW -> "slow(${DateTimeUtils.formatSeconds(value)})" + else -> tag.name.lowercase() } }.joinToString() @@ -53,12 +49,12 @@ data class RoomState( .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.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) + 0 -> TextResource.Res(R.string.room_state_follower_only) else -> TextResource.Res(R.string.room_state_follower_only_duration, persistentListOf(DateTimeUtils.formatSeconds(value * 60))) } } 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 6e5020c5a..5cf6746c7 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,6 +1,7 @@ package com.flxrs.dankchat.data.twitch.pubsub import android.util.Log +import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.ifBlank import com.flxrs.dankchat.data.toUserId @@ -255,20 +256,20 @@ class PubSubConnection( val json = JSONObject(text) val type = json.optString("type").ifBlank { return false } when (type) { - "PONG" -> awaitingPong = false + "PONG" -> awaitingPong = false "RECONNECT" -> { Log.i(TAG, "[PubSub $tag] server requested reconnect") return true } - "RESPONSE" -> { + "RESPONSE" -> { val error = json.optString("error") if (error.isNotBlank()) { Log.w(TAG, "[PubSub $tag] RESPONSE error: $error") } } - "MESSAGE" -> { + "MESSAGE" -> { val data = json.optJSONObject("data") ?: return false val topic = data.optString("topic").ifBlank { return false } val message = data.optString("message").ifBlank { return false } @@ -276,7 +277,7 @@ class PubSubConnection( val messageTopic = messageObject.optString("type") val match = topics.find { topic == it.topic } ?: return false val pubSubMessage = when (match) { - is PubSubTopic.Whispers -> { + is PubSubTopic.Whispers -> { if (messageTopic !in listOf("whisper_sent", "whisper_received")) { return false } @@ -295,13 +296,13 @@ class PubSubConnection( 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" -> { + "moderator_added" -> { val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false val timestamp = Clock.System.now() PubSubMessage.ModeratorAction( @@ -315,8 +316,8 @@ class PubSubConnection( creatorUserId = parsedMessage.data.creatorUserId, creator = parsedMessage.data.creator, createdAt = timestamp.toString(), - msgId = null, - ), + msgId = null + ) ) } @@ -327,7 +328,7 @@ class PubSubConnection( } val timestamp = when { parsedMessage.data.createdAt.isEmpty() -> Clock.System.now() - else -> Instant.parse(parsedMessage.data.createdAt) + else -> Instant.parse(parsedMessage.data.createdAt) } PubSubMessage.ModeratorAction( timestamp = timestamp, @@ -338,11 +339,11 @@ class PubSubConnection( creatorUserId = parsedMessage.data.creatorUserId?.ifBlank { null }, targetUserId = parsedMessage.data.targetUserId?.ifBlank { null }, targetUserName = parsedMessage.data.targetUserName?.ifBlank { null }, - ), + ) ) } - else -> return false + else -> return false } } } From ff3eebde17c3a487752c3803c2a1c94b7d78cbc1 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 21:04:29 +0200 Subject: [PATCH 165/349] style: Remove stale comment from DankChatModule --- app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt index 89537dde1..a787fdf99 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DankChatModule.kt @@ -5,4 +5,4 @@ import org.koin.core.annotation.Module @Module(includes = [ConnectionModule::class, DatabaseModule::class, NetworkModule::class, CoroutineModule::class]) @ComponentScan("com.flxrs.dankchat") -class DankChatModule // ksp regen v3 +class DankChatModule From 1e8c84acdbe7a53aceb636a2ab7294140187a649 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 23:00:12 +0200 Subject: [PATCH 166/349] refactor(main): Extract MainScreen components, clean up dead code, add user emote reload --- .../dankchat/domain/ChannelDataCoordinator.kt | 14 + .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 20 - .../flxrs/dankchat/ui/main/MainActivity.kt | 6 - .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 2 + .../com/flxrs/dankchat/ui/main/MainScreen.kt | 529 ++++-------------- .../dankchat/ui/main/MainScreenComponents.kt | 233 ++++++++ .../ui/main/MainScreenPagerContent.kt | 189 +++++++ .../dankchat/ui/main/MainScreenViewModel.kt | 5 +- .../dankchat/ui/main/StreamToolbarState.kt | 74 +++ .../flxrs/dankchat/ui/main/ToolbarAction.kt | 25 + .../channel/ChannelManagementViewModel.kt | 1 + .../dankchat/ui/main/channel/ChannelTab.kt | 43 -- .../dankchat/ui/main/channel/ChannelTabRow.kt | 28 - .../ui/main/input/ChatInputCallbacks.kt | 20 + .../dankchat/ui/main/input/ChatInputLayout.kt | 254 --------- .../ui/main/input/ChatInputViewModel.kt | 121 ++-- .../ui/main/input/InputActionConfig.kt | 207 +++++++ .../dankchat/ui/main/input/TourOverlay.kt | 98 ++++ 18 files changed, 1029 insertions(+), 840 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTab.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputCallbacks.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/InputActionConfig.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/TourOverlay.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 8f149f44c..3272f8cd6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -189,6 +189,20 @@ class ChannelDataCoordinator( } } + 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() + } + } + /** * Reload global data */ 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 index 866076e19..3869f3450 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -106,26 +106,6 @@ import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first import kotlin.coroutines.cancellation.CancellationException -sealed interface ToolbarAction { - data class SelectTab(val index: Int) : ToolbarAction - data class LongClickTab(val index: Int) : 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 -} @Suppress("MultipleEmitters") @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) 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 index 7426df9e8..8d6e9caba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -257,12 +257,6 @@ class MainActivity : AppCompatActivity() { startActivity(it) } }, - onReloadEmotes = { - // Handled in MainScreen with ViewModel - }, - onReconnect = { - // Handled in MainScreen with ViewModel - }, onCaptureImage = { startCameraCapture(captureVideo = false) }, 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 index 0ff0f5b7f..7530339f8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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 @@ -69,6 +70,7 @@ import com.composables.core.rememberScrollAreaState import com.flxrs.dankchat.R import kotlinx.coroutines.CancellationException +@Immutable sealed interface AppBarMenu { data object Main : AppBarMenu data object Upload : AppBarMenu 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 index c4ff7a54a..e986d9ab1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -1,33 +1,15 @@ package com.flxrs.dankchat.ui.main -import android.app.Activity -import android.app.PictureInPictureParams -import android.os.Build -import android.util.Rational import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.tween -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.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.Column 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.fillMaxHeight 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.imeAnimationTarget import androidx.compose.foundation.layout.imePadding @@ -37,24 +19,17 @@ 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.systemGestures -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.pager.HorizontalPager 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.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -65,46 +40,27 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned 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.LocalLayoutDirection import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.LocalView 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.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowSizeClass 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.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.InputAction -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.ScrollDirectionTracker import com.flxrs.dankchat.ui.chat.mention.MentionViewModel -import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams import com.flxrs.dankchat.ui.chat.swipeDownToHide -import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel import com.flxrs.dankchat.ui.main.channel.ChannelPagerViewModel import com.flxrs.dankchat.ui.main.channel.ChannelTabViewModel @@ -116,7 +72,6 @@ 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.EmoteMenu import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetOverlay import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState import com.flxrs.dankchat.ui.main.sheet.SheetNavigationViewModel @@ -127,6 +82,7 @@ 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.persistentListOf +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -149,18 +105,13 @@ fun MainScreen( onOpenChannel: () -> Unit, onReportChannel: () -> Unit, onOpenUrl: (String) -> Unit, - onReloadEmotes: () -> Unit, - onReconnect: () -> Unit, onCaptureImage: () -> Unit, onCaptureVideo: () -> Unit, onChooseMedia: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val context = LocalContext.current val density = LocalDensity.current val messageNotInHistoryMsg = stringResource(R.string.message_not_in_history) - val layoutDirection = LocalLayoutDirection.current - // Scoped ViewModels - each handles one concern val mainScreenViewModel: MainScreenViewModel = koinViewModel() val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() val channelTabViewModel: ChannelTabViewModel = koinViewModel() @@ -224,37 +175,16 @@ fun MainScreen( val streamVmState by streamViewModel.streamState.collectAsStateWithLifecycle() val currentStream = streamVmState.currentStream val hasStreamData = streamVmState.hasStreamData - val imeTargetBottom = with(density) { WindowInsets.imeAnimationTarget.getBottom(density) } + val imeTargetBottom = WindowInsets.imeAnimationTarget.getBottom(density) val streamState = rememberStreamToolbarState(currentStream, isKeyboardVisible, imeTargetBottom) // PiP state — observe via lifecycle since onPause fires when entering PiP - 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() - ) - } - } - } + 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 + WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND, ) val useWideSplitLayout = isWideWindow && currentStream != null && !isInPipMode @@ -306,7 +236,7 @@ fun MainScreen( // Tooltip .show() calls live in FloatingToolbar. LaunchedEffect(featureTourState.postOnboardingStep) { when (featureTourState.postOnboardingStep) { - PostOnboardingStep.FeatureTour -> { + PostOnboardingStep.FeatureTour -> { featureTourViewModel.addChannelTooltipState.dismiss() featureTourViewModel.startTour() } @@ -315,7 +245,7 @@ fun MainScreen( featureTourViewModel.addChannelTooltipState.dismiss() } - PostOnboardingStep.ToolbarPlusHint -> Unit + PostOnboardingStep.ToolbarPlusHint -> Unit } } @@ -384,34 +314,16 @@ fun MainScreen( onShow = { mainScreenViewModel.setGestureToolbarHidden(false) }, ) } - val chatScrollModifier = Modifier - .nestedScroll(toolbarTracker) val swipeDownThresholdPx = with(density) { 56.dp.toPx() } - // Hide/show system bars when fullscreen toggles - 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 { - // Restore system bars when leaving composition in fullscreen - controller.show(WindowInsetsCompat.Type.systemBars()) - } - } + FullscreenSystemBarsEffect(isFullscreen) val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() val composePagerState = rememberPagerState( initialPage = pagerState.currentPage, - pageCount = { pagerState.channels.size } + pageCount = { pagerState.channels.size }, ).also { composePagerStateRef = it } var inputHeightPx by remember { mutableIntStateOf(0) } var helperTextHeightPx by remember { mutableIntStateOf(0) } @@ -420,7 +332,6 @@ fun MainScreen( if (effectiveShowInput || inputState.helperText.isEmpty) helperTextHeightPx = 0 val inputHeightDp = with(density) { inputHeightPx.toDp() } val helperTextHeightDp = with(density) { helperTextHeightPx.toDp() } - // scaffoldBottomContentPadding removed — input bar rendered outside Scaffold // Clear focus when keyboard fully reaches the bottom, but not when // switching to the emote menu. Prevents keyboard from reopening when @@ -482,7 +393,7 @@ fun MainScreen( Box( modifier = Modifier .fillMaxSize() - .then(if (!isFullscreen && !isInPipMode) Modifier.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) else Modifier) + .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) { @@ -498,7 +409,7 @@ fun MainScreen( val roundedCornerBottomPadding = rememberRoundedCornerBottomPadding() val effectiveRoundedCorner = when { roundedCornerBottomPadding >= ROUNDED_CORNER_THRESHOLD -> roundedCornerBottomPadding - else -> 0.dp + else -> 0.dp } val totalMenuHeight = targetMenuHeight + navBarHeightDp @@ -527,10 +438,10 @@ fun MainScreen( }, onOverlayDismiss = { when (inputState.overlay) { - is InputOverlay.Reply -> chatInputViewModel.setReplying(false) - is InputOverlay.Whisper -> chatInputViewModel.setWhisperTarget(null) + is InputOverlay.Reply -> chatInputViewModel.setReplying(false) + is InputOverlay.Whisper -> chatInputViewModel.setWhisperTarget(null) is InputOverlay.Announce -> chatInputViewModel.setAnnouncing(false) - InputOverlay.None -> Unit + InputOverlay.None -> Unit } }, onToggleFullscreen = mainScreenViewModel::toggleFullscreen, @@ -538,7 +449,7 @@ fun MainScreen( onToggleStream = { when { currentStream != null -> streamViewModel.closeStream() - else -> activeChannel?.let { streamViewModel.toggleStream(it) } + else -> activeChannel?.let { streamViewModel.toggleStream(it) } } }, onModActions = dialogViewModel::showModActions, @@ -560,13 +471,15 @@ fun MainScreen( inputActions = when (fullScreenSheetState) { is FullScreenSheetState.Replies -> persistentListOf(InputAction.LastMessage) is FullScreenSheetState.Whisper, - is FullScreenSheetState.Mention -> when { + is FullScreenSheetState.Mention, + -> when { inputState.isWhisperTabActive && inputState.overlay is InputOverlay.Whisper -> persistentListOf(InputAction.LastMessage) - else -> persistentListOf() + else -> persistentListOf() } is FullScreenSheetState.History, - is FullScreenSheetState.Closed -> mainState.inputActions + is FullScreenSheetState.Closed, + -> mainState.inputActions }, onInputHeightChange = { inputHeightPx = it }, debugMode = mainState.debugMode, @@ -584,7 +497,7 @@ fun MainScreen( swipeGestureTooltipState = if (featureTourState.currentTourStep == TourStep.SwipeGesture) featureTourViewModel.swipeGestureTooltipState else null, forceOverflowOpen = featureTourState.forceOverflowOpen, isTourActive = featureTourState.isTourActive - || featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint, + || featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint, onAdvance = featureTourViewModel::advance, onSkip = featureTourViewModel::skipTour, ) @@ -595,7 +508,7 @@ fun MainScreen( // Shared toolbar action handler val handleToolbarAction: (ToolbarAction) -> Unit = { action -> when (action) { - is ToolbarAction.SelectTab -> { + is ToolbarAction.SelectTab -> { channelTabViewModel.selectTab(action.index) scope.launch { composePagerState.scrollToPage(action.index) } } @@ -605,47 +518,45 @@ fun MainScreen( scope.launch { composePagerState.scrollToPage(action.index) } } - ToolbarAction.AddChannel -> { + ToolbarAction.AddChannel -> { featureTourViewModel.onAddedChannelFromToolbar() dialogViewModel.showAddChannel() } - ToolbarAction.OpenMentions -> { + 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 -> { + 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 -> { + ToolbarAction.CaptureVideo -> { if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else dialogViewModel.setPendingUploadAction(onCaptureVideo) } - ToolbarAction.ChooseMedia -> { + ToolbarAction.ChooseMedia -> { if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else dialogViewModel.setPendingUploadAction(onChooseMedia) } - ToolbarAction.ReloadEmotes -> { + ToolbarAction.ReloadEmotes -> { activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - onReloadEmotes() } - ToolbarAction.Reconnect -> { + ToolbarAction.Reconnect -> { channelManagementViewModel.reconnect() - onReconnect() } - ToolbarAction.OpenSettings -> onNavigateToSettings() + ToolbarAction.OpenSettings -> onNavigateToSettings() } } @@ -674,188 +585,71 @@ fun MainScreen( // Shared emote menu layer val emoteMenuLayer: @Composable (Modifier) -> Unit = { menuModifier -> - AnimatedVisibility( - visible = inputState.isEmoteMenuOpen, - enter = slideInVertically(animationSpec = tween(durationMillis = 140), initialOffsetY = { it }), - exit = slideOutVertically(animationSpec = tween(durationMillis = 140), targetOffsetY = { it }), - modifier = menuModifier - ) { - 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 = { code, id -> - chatInputViewModel.insertText("$code ") - chatInputViewModel.addEmoteUsage(id) - }, - onBackspace = chatInputViewModel::deleteLastWord, - modifier = Modifier.fillMaxSize() - ) - } - } + 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 = { + if (mainScreenViewModel.uiState.value.isFullscreen) mainScreenViewModel.toggleFullscreen() + if (!mainScreenViewModel.uiState.value.showInput) mainScreenViewModel.toggleInput() + mainScreenViewModel.resetGestureState() + }, + onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, + onTourAdvance = featureTourViewModel::advance, + onTourSkip = featureTourViewModel::skipTour, + scrollConnection = toolbarTracker, + ) } // Shared scaffold content (pager) val scaffoldContent: @Composable (PaddingValues, Dp) -> Unit = { paddingValues, chatTopPadding -> - // Input bar is rendered outside Scaffold, so calculateBottomPadding() is 0 here - 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 = dialogViewModel::showAddChannel, - 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(), - 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 (inputState.userLongClickBehavior) { - UserLongClickBehavior.MentionsUser -> !isLongPress - UserLongClickBehavior.OpensPopup -> isLongPress - } - if (shouldOpenPopup) { - dialogViewModel.showUserPopup( - UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) - ) - } else { - chatInputViewModel.mentionUser(UserName(userName), DisplayName(displayName)) - } - }, - onMessageLongClick = { messageId, channel, fullMessage -> - dialogViewModel.showMessageOptions( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = isLoggedIn, - canReply = isLoggedIn, - canCopy = true - ) - ) - }, - onEmoteClick = { emotes -> - dialogViewModel.showEmoteInfo(emotes) - }, - onReplyClick = { replyMessageId, replyName -> - sheetNavigationViewModel.openReplies(replyMessageId, replyName) - }, - showInput = effectiveShowInput, - isFullscreen = isFullscreen, - showFabs = !isSheetOpen, - onRecover = { - if (isFullscreen) mainScreenViewModel.toggleFullscreen() - if (!mainState.showInput) mainScreenViewModel.toggleInput() - mainScreenViewModel.resetGestureState() - }, - contentPadding = PaddingValues( - top = chatTopPadding + 56.dp, - bottom = paddingValues.calculateBottomPadding() + when { - effectiveShowInput -> inputHeightDp - !isFullscreen -> when { - helperTextHeightDp > 0.dp -> helperTextHeightDp - else -> max(navBarHeightDp, effectiveRoundedCorner) - } - - else -> when { - helperTextHeightDp > 0.dp -> helperTextHeightDp - else -> effectiveRoundedCorner - } - } - ), - scrollModifier = chatScrollModifier, - onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, - onScrollDirectionChange = { }, - scrollToMessageId = scrollTargets[channel], - onScrollToMessageHandle = { scrollTargets.remove(channel) }, - recoveryFabTooltipState = if (featureTourState.currentTourStep == TourStep.RecoveryFab) featureTourViewModel.recoveryFabTooltipState else null, - onTourAdvance = featureTourViewModel::advance, - onTourSkip = featureTourViewModel::skipTour, - ) - } - } - - // Edge gesture guards — consume touch to prevent pager swipes near screen edges. - // Uses physical left/right (not logical start/end) since system gesture - // insets are always physical regardless of layout direction. - val systemGestureInsets = WindowInsets.systemGestures - val edgeGuardModifier = Modifier - .fillMaxHeight() - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown(pass = PointerEventPass.Initial) - down.consume() - do { - val event = awaitPointerEvent(pass = PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } while (event.changes.any { it.pressed }) - } - } - - // Left edge guard - Box( - modifier = Modifier - .align(AbsoluteAlignment.CenterLeft) - .width(with(density) { systemGestureInsets.getLeft(density, layoutDirection).toDp() }) - .then(edgeGuardModifier) - ) - // Right edge guard - Box( - modifier = Modifier - .align(AbsoluteAlignment.CenterRight) - .width(with(density) { systemGestureInsets.getRight(density, layoutDirection).toDp() }) - .then(edgeGuardModifier) - ) - } - } - } - } + MainScreenPagerContent( + paddingValues = paddingValues, + chatTopPadding = chatTopPadding, + tabState = tabState, + composePagerState = composePagerState, + pagerState = pagerState, + isLoggedIn = isLoggedIn, + effectiveShowInput = effectiveShowInput, + isFullscreen = isFullscreen, + isSheetOpen = isSheetOpen, + inputHeightDp = inputHeightDp, + helperTextHeightDp = helperTextHeightDp, + navBarHeightDp = navBarHeightDp, + effectiveRoundedCorner = effectiveRoundedCorner, + userLongClickBehavior = inputState.userLongClickBehavior, + scrollTargets = scrollTargets.toImmutableMap(), + onClearScrollTarget = { scrollTargets.remove(it) }, + callbacks = chatPagerCallbacks, + currentTourStep = featureTourState.currentTourStep, + recoveryFabTooltipState = featureTourViewModel.recoveryFabTooltipState, + onAddChannel = dialogViewModel::showAddChannel, + onLogin = onLogin, + ) } // Shared fullscreen sheet overlay val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> val effectiveBottomPadding = when { !effectiveShowInput -> bottomPadding + max(navBarHeightDp, effectiveRoundedCorner) - else -> bottomPadding + else -> bottomPadding } FullScreenSheetOverlay( sheetState = fullScreenSheetState, @@ -883,14 +677,14 @@ fun MainScreen( Box( modifier = Modifier .fillMaxSize() - .onGloballyPositioned { containerWidthPx = it.size.width } + .onGloballyPositioned { containerWidthPx = it.size.width }, ) { Row(modifier = Modifier.fillMaxSize()) { // Left pane: Stream Box( modifier = Modifier .weight(splitFraction) - .fillMaxSize() + .fillMaxSize(), ) { StreamView( channel = currentStream, @@ -901,7 +695,7 @@ fun MainScreen( focusManager.clearFocus() streamViewModel.closeStream() }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) } @@ -909,7 +703,7 @@ fun MainScreen( Box( modifier = Modifier .weight(1f - splitFraction) - .fillMaxSize() + .fillMaxSize(), ) { val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -940,30 +734,16 @@ fun MainScreen( // Status bar scrim when toolbar is gesture-hidden if (!isFullscreen && mainState.gestureToolbarHidden) { - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) - ) + StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) } fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) // Dismiss scrim for input overflow menu if (inputOverflowExpanded) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - if (!featureTourState.forceOverflowOpen) { - inputOverflowExpanded = false - } - } + InputDismissScrim( + forceOpen = featureTourState.forceOverflowOpen, + onDismiss = { inputOverflowExpanded = false }, ) } @@ -976,7 +756,7 @@ fun MainScreen( enabled = effectiveShowInput, thresholdPx = swipeDownThresholdPx, onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ) + ), ) { bottomBar() } @@ -991,7 +771,7 @@ fun MainScreen( .align(Alignment.BottomStart) .navigationBarsPadding() .imePadding() - .padding(bottom = inputHeightDp + 2.dp) + .padding(bottom = inputHeightDp + 2.dp), ) } } @@ -1006,7 +786,7 @@ fun MainScreen( }, modifier = Modifier .align(Alignment.CenterStart) - .graphicsLayer { translationX = containerWidthPx * splitFraction - 12.dp.toPx() } + .graphicsLayer { translationX = containerWidthPx * splitFraction - 12.dp.toPx() }, ) } } else { @@ -1063,7 +843,7 @@ fun MainScreen( .onGloballyPositioned { coordinates -> streamState.heightDp = with(density) { coordinates.size.height.toDp() } } - } + }, ) } if (!showStream) { @@ -1073,13 +853,11 @@ fun MainScreen( // Status bar scrim when stream is active — fades with stream/toolbar if (currentStream != null && !isFullscreen && !isInPipMode) { - Box( + StatusBarScrim( + colorAlpha = 1f, modifier = Modifier .align(Alignment.TopCenter) - .fillMaxWidth() - .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .graphicsLayer { alpha = streamState.alpha.value } - .background(MaterialTheme.colorScheme.surface) + .graphicsLayer { alpha = streamState.alpha.value }, ) } @@ -1093,13 +871,7 @@ fun MainScreen( // Status bar scrim when toolbar is gesture-hidden — keeps status bar readable if (!isInPipMode && !isFullscreen && mainState.gestureToolbarHidden) { - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) - ) + StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) } // Fullscreen Overlay Sheets — after toolbar/scrims so sheets render on top @@ -1109,17 +881,9 @@ fun MainScreen( // Dismiss scrim for input overflow menu — before input bar so menu items stay clickable if (!isInPipMode && inputOverflowExpanded) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - if (!featureTourState.forceOverflowOpen) { - inputOverflowExpanded = false - } - } + InputDismissScrim( + forceOpen = featureTourState.forceOverflowOpen, + onDismiss = { inputOverflowExpanded = false }, ) } @@ -1133,7 +897,7 @@ fun MainScreen( enabled = effectiveShowInput, thresholdPx = swipeDownThresholdPx, onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ) + ), ) { bottomBar() } @@ -1151,74 +915,9 @@ fun MainScreen( .align(Alignment.BottomStart) .navigationBarsPadding() .imePadding() - .padding(bottom = inputHeightDp + 2.dp) + .padding(bottom = inputHeightDp + 2.dp), ) } } } } - -@Stable -private class StreamToolbarState( - val alpha: Animatable, -) { - var heightDp by mutableStateOf(0.dp) - private var prevHasVisibleStream by mutableStateOf(false) - private var isKeyboardClosingWithStream by mutableStateOf(false) - private var wasKeyboardClosingWithStream by mutableStateOf(false) - - val hasVisibleStream: Boolean - get() = heightDp > 0.dp - - /** - * Returns the effective toolbar alpha, accounting for the bridge state - * between keyboard closing and stream becoming visible. - */ - val effectiveAlpha: Float - get() = if (hasVisibleStream || isKeyboardClosingWithStream || wasKeyboardClosingWithStream) alpha.value else 1f - - suspend fun updateAnimation(hasVisibleStream: Boolean, keyboardClosingWithStream: Boolean) { - isKeyboardClosingWithStream = keyboardClosingWithStream - if (keyboardClosingWithStream) wasKeyboardClosingWithStream = true - if (hasVisibleStream) wasKeyboardClosingWithStream = false - - when { - keyboardClosingWithStream -> { - alpha.animateTo(0f, tween(durationMillis = 150)) - } - - hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { - prevHasVisibleStream = hasVisibleStream - alpha.snapTo(0f) - alpha.animateTo(1f, tween(durationMillis = 350)) - } - - !hasVisibleStream && hasVisibleStream != prevHasVisibleStream -> { - prevHasVisibleStream = hasVisibleStream - alpha.snapTo(0f) - } - } - } -} - -@Composable -private fun rememberStreamToolbarState( - currentStream: UserName?, - isKeyboardVisible: Boolean, - imeTargetBottom: Int, -): StreamToolbarState { - val state = remember { StreamToolbarState(alpha = Animatable(0f)) } - - val hasVisibleStream = currentStream != null && state.heightDp > 0.dp - val isKeyboardClosingWithStream = currentStream != null && isKeyboardVisible && imeTargetBottom == 0 - - LaunchedEffect(hasVisibleStream, isKeyboardClosingWithStream) { - state.updateAnimation(hasVisibleStream, isKeyboardClosingWithStream) - } - LaunchedEffect(currentStream) { - if (currentStream == null) state.heightDp = 0.dp - } - - return state -} - 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..5d7194ffa --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -0,0 +1,233 @@ +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.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.BoxScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +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.foundation.layout.width +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.AbsoluteAlignment +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.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.main.sheet.EmoteMenu +import com.flxrs.dankchat.ui.main.stream.StreamViewModel + +/** + * Observes PiP mode via lifecycle and configures auto-enter PiP parameters. + * Returns whether the activity is currently in PiP mode. + */ +@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 +} + +/** + * Manages system bar visibility based on fullscreen state. + * Hides system bars when fullscreen, restores them when leaving. + */ +@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()) + } + } +} + +/** + * Status bar scrim that keeps text readable when content scrolls behind. + * [colorAlpha] controls the background color opacity (e.g. 0.7f for semi-transparent). + * Additional graphicsLayer transforms (e.g. fade with stream) can be applied via [modifier]. + */ +@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)) + ) +} + +/** + * Fullscreen scrim that dismisses the input overflow menu when tapped. + */ +@Composable +internal fun InputDismissScrim( + forceOpen: Boolean, + onDismiss: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (!forceOpen) { + onDismiss() + } + } + ) +} + +/** + * Invisible touch-consuming boxes at the left and right screen edges. + * Prevents the HorizontalPager from intercepting system back/edge gestures. + */ +@Composable +internal fun BoxScope.EdgeGestureGuards() { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val systemGestureInsets = WindowInsets.systemGestures + + val edgeGuardModifier = Modifier + .fillMaxHeight() + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + down.consume() + do { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + } + } + + // Left edge guard + Box( + modifier = Modifier + .align(AbsoluteAlignment.CenterLeft) + .width(with(density) { systemGestureInsets.getLeft(density, layoutDirection).toDp() }) + .then(edgeGuardModifier) + ) + // Right edge guard + Box( + modifier = Modifier + .align(AbsoluteAlignment.CenterRight) + .width(with(density) { systemGestureInsets.getRight(density, layoutDirection).toDp() }) + .then(edgeGuardModifier) + ) +} + +/** + * 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/MainScreenPagerContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt new file mode 100644 index 000000000..346828b96 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -0,0 +1,189 @@ +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.preferences.chat.UserLongClickBehavior +import com.flxrs.dankchat.preferences.components.DankBackground +import com.flxrs.dankchat.ui.chat.ChatComposable +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import kotlinx.collections.immutable.ImmutableMap +import com.flxrs.dankchat.ui.main.channel.ChannelPagerUiState +import com.flxrs.dankchat.ui.main.channel.ChannelTabUiState +import com.flxrs.dankchat.ui.tour.TourStep + +/** + * Callbacks for chat message interactions within the pager. + */ +@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, +) + +/** + * Scaffold content containing the channel pager, loading states, and edge gesture guards. + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun MainScreenPagerContent( + paddingValues: PaddingValues, + chatTopPadding: Dp, + tabState: ChannelTabUiState, + composePagerState: PagerState, + pagerState: ChannelPagerUiState, + isLoggedIn: Boolean, + effectiveShowInput: Boolean, + isFullscreen: Boolean, + isSheetOpen: Boolean, + inputHeightDp: Dp, + helperTextHeightDp: Dp, + navBarHeightDp: Dp, + effectiveRoundedCorner: Dp, + userLongClickBehavior: UserLongClickBehavior, + scrollTargets: ImmutableMap, + onClearScrollTarget: (UserName) -> Unit, + callbacks: ChatPagerCallbacks, + 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(), + 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 = effectiveShowInput, + isFullscreen = isFullscreen, + showFabs = !isSheetOpen, + onRecover = callbacks.onRecover, + contentPadding = PaddingValues( + top = chatTopPadding + 56.dp, + bottom = paddingValues.calculateBottomPadding() + when { + effectiveShowInput -> 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, + ) + } + } + + EdgeGestureGuards() + } + } + } + } +} 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 index c6cf5b539..f986137b2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -48,7 +48,6 @@ class MainScreenViewModel( private val userStateRepository: UserStateRepository, ) : ViewModel() { - // Only expose truly global state val globalLoadingState: StateFlow = channelDataCoordinator.globalLoadingState @@ -148,9 +147,7 @@ class MainScreenViewModel( } } - fun reloadGlobalData() { - channelDataCoordinator.reloadGlobalData() - } + fun toggleInput() { viewModelScope.launch { 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..6f9caddaf --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt @@ -0,0 +1,74 @@ +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) + private var isKeyboardClosingWithStream by mutableStateOf(false) + private var wasKeyboardClosingWithStream by mutableStateOf(false) + + val hasVisibleStream: Boolean + get() = heightDp > 0.dp + + val effectiveAlpha: Float + get() = if (hasVisibleStream || isKeyboardClosingWithStream || wasKeyboardClosingWithStream) alpha.value else 1f + + suspend fun updateAnimation(hasVisibleStream: Boolean, keyboardClosingWithStream: Boolean) { + isKeyboardClosingWithStream = keyboardClosingWithStream + if (keyboardClosingWithStream) wasKeyboardClosingWithStream = true + if (hasVisibleStream) wasKeyboardClosingWithStream = false + + when { + keyboardClosingWithStream -> { + alpha.animateTo(0f, tween(durationMillis = 150)) + } + + hasVisibleStream && !prevHasVisibleStream -> { + prevHasVisibleStream = true + alpha.snapTo(0f) + alpha.animateTo(1f, tween(durationMillis = 350)) + } + + !hasVisibleStream && prevHasVisibleStream -> { + prevHasVisibleStream = false + alpha.snapTo(0f) + } + } + } +} + +@Composable +internal fun rememberStreamToolbarState( + currentStream: UserName?, + isKeyboardVisible: Boolean, + imeTargetBottom: Int, +): StreamToolbarState { + val state = remember { StreamToolbarState(alpha = Animatable(0f)) } + + val hasVisibleStream = currentStream != null && state.heightDp > 0.dp + val isKeyboardClosingWithStream = currentStream != null && isKeyboardVisible && imeTargetBottom == 0 + + LaunchedEffect(hasVisibleStream, isKeyboardClosingWithStream) { + state.updateAnimation(hasVisibleStream, isKeyboardClosingWithStream) + } + LaunchedEffect(currentStream) { + if (currentStream == null) state.heightDp = 0.dp + } + + 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..14423b99e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt @@ -0,0 +1,25 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface ToolbarAction { + data class SelectTab(val index: Int) : ToolbarAction + data class LongClickTab(val index: Int) : 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 index 39bf6b8b8..82586acc0 100644 --- 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 @@ -107,6 +107,7 @@ class ChannelManagementViewModel( fun reloadEmotes(channel: UserName) { channelDataCoordinator.loadChannelData(channel) + channelDataCoordinator.reloadUserEmotes() } fun reconnect() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTab.kt deleted file mode 100644 index a4767e8aa..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTab.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.flxrs.dankchat.ui.main.channel - -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable - -@Composable -fun ChannelTab( - tab: ChannelTabItem, - onClick: () -> Unit -) { - val tabColor = when { - tab.isSelected -> MaterialTheme.colorScheme.primary - // Unread or Mentioned -> High visibility (OnSurface) - tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface - // Idle -> Lower visibility - else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - } - - Tab( - selected = tab.isSelected, - onClick = onClick, - selectedContentColor = tabColor, - unselectedContentColor = tabColor, - text = { - BadgedBox( - badge = { - if (tab.mentionCount > 0) { - Badge() - } - } - ) { - Text( - text = tab.displayName, - color = tabColor - ) - } - } - ) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt deleted file mode 100644 index b73f533c9..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabRow.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.flxrs.dankchat.ui.main.channel - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.PrimaryScrollableTabRow -import androidx.compose.runtime.Composable -import kotlinx.collections.immutable.ImmutableList - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -fun ChannelTabRow( - tabs: ImmutableList, - selectedIndex: Int, - onTabSelect: (Int) -> Unit -) { - PrimaryScrollableTabRow( - selectedTabIndex = selectedIndex, - ) { - tabs.forEachIndexed { index, tab -> - ChannelTab( - tab = tab, - onClick = { - onTabSelect(index) - } - ) - } - } -} 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..7b9b5ffeb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputCallbacks.kt @@ -0,0 +1,20 @@ +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 onModActions: () -> Unit, + val onInputActionsChange: (ImmutableList) -> Unit, + val onSearchClick: () -> Unit = {}, + val onDebugInfoClick: () -> Unit = {}, + val onNewWhisper: (() -> Unit)? = null, + val onRepeatedSendChange: (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 index 4be13df96..f5c02285f 100644 --- 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 @@ -107,37 +107,6 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import sh.calvin.reorderable.ReorderableColumn -private const val MAX_INPUT_ACTIONS = 4 - -@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, -) - -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 onModActions: () -> Unit, - val onInputActionsChange: (ImmutableList) -> Unit, - val onSearchClick: () -> Unit = {}, - val onDebugInfoClick: () -> Unit = {}, - val onNewWhisper: (() -> Unit)? = null, - val onRepeatedSendChange: (Boolean) -> Unit = {}, -) - @Composable fun ChatInputLayout( textFieldState: TextFieldState, @@ -494,165 +463,6 @@ fun ChatInputLayout( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private 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, - ) - } - } - } - } -} - -private 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 - } - -private val InputAction.icon: ImageVector - get() = when (this) { - InputAction.Search -> Icons.Default.Search - InputAction.LastMessage -> Icons.Default.History - InputAction.Stream -> Icons.Default.Videocam - InputAction.ModActions -> Icons.Default.Shield - InputAction.Fullscreen -> Icons.Default.Fullscreen - InputAction.HideInput -> Icons.Default.VisibilityOff - InputAction.Debug -> Icons.Default.BugReport - } - @Composable private fun SendButton( enabled: Boolean, @@ -1009,68 +819,4 @@ private fun EndAlignedActionGroup( ) } -@Suppress("ContentSlotReused") -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private 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, -) { - 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 = 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/input/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt index 65d94c893..20093673b 100644 --- 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 @@ -67,26 +67,23 @@ class ChatInputViewModel( private val suggestionProvider: SuggestionProvider, private val preferenceStore: DankChatPreferenceStore, private val chatSettingsDataStore: ChatSettingsDataStore, - private val streamDataRepository: StreamDataRepository, - private val streamsSettingsDataStore: StreamsSettingsDataStore, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val notificationsSettingsDataStore: NotificationsSettingsDataStore, private val emoteUsageRepository: EmoteUsageRepository, private val mainEventBus: MainEventBus, + streamsSettingsDataStore: StreamsSettingsDataStore, + streamDataRepository: StreamDataRepository, ) : ViewModel() { val textFieldState = TextFieldState() private val _isReplying = MutableStateFlow(false) - val isReplying: StateFlow = _isReplying - private val _replyMessageId = MutableStateFlow(null) private val _replyName = 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) - val isEmoteMenuOpen = _isEmoteMenuOpen.asStateFlow() private val _whisperTarget = MutableStateFlow(null) private var lastWhisperText: String? = null @@ -94,7 +91,6 @@ class ChatInputViewModel( private val _isAnnouncing = MutableStateFlow(false) - // Create flow from TextFieldState tracking both text and cursor position private val codePointCount = snapshotFlow { val text = textFieldState.text text.toString().codePointCount(0, text.length) @@ -119,14 +115,14 @@ class ChatInputViewModel( val (text, cursorPos, channel) = triple when { enabled -> suggestionProvider.getSuggestions(text, cursorPos, channel) - else -> flowOf(emptyList()) + else -> flowOf(emptyList()) } }.map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) private val roomStateResources: StateFlow> = combine( chatSettingsDataStore.showChatModes, - chatChannelProvider.activeChannel + chatChannelProvider.activeChannel, ) { showModes, channel -> showModes to channel }.flatMapLatest { (showModes, channel) -> @@ -139,7 +135,7 @@ class ChatInputViewModel( private val currentStreamInfo: StateFlow = combine( streamsSettingsDataStore.showStreamsInfo, chatChannelProvider.activeChannel, - streamDataRepository.streamData + streamDataRepository.streamData, ) { streamInfoEnabled, activeChannel, streamData -> streamData.find { it.channel == activeChannel }?.formattedData?.takeIf { streamInfoEnabled } }.distinctUntilChanged() @@ -147,7 +143,7 @@ class ChatInputViewModel( private val helperText: StateFlow = combine( roomStateResources, - currentStreamInfo + currentStreamInfo, ) { roomState, streamInfo -> HelperText( roomStateParts = roomState.toImmutableList(), @@ -195,26 +191,6 @@ class ChatInputViewModel( } - private data class UiDependencies( - val text: String, - val suggestions: List, - val activeChannel: UserName?, - val connectionState: ConnectionState, - val isLoggedIn: Boolean, - val autoDisableInput: Boolean - ) - - private data class InputOverlayState( - val sheetState: FullScreenSheetState, - val tab: Int, - val isReplying: Boolean, - val replyName: UserName?, - val replyMessageId: String?, - val isEmoteMenuOpen: Boolean, - val whisperTarget: UserName?, - val isAnnouncing: Boolean, - ) - fun uiState(externalSheetState: StateFlow, externalMentionTab: StateFlow): StateFlow { _uiState?.let { return it } @@ -236,7 +212,7 @@ class ChatInputViewModel( if (channel == null) flowOf(ConnectionState.DISCONNECTED) else chatConnector.getConnectionState(channel) }, - combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b } + combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b }, ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, autoDisableInput) -> UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput) } @@ -244,7 +220,7 @@ class ChatInputViewModel( val replyStateFlow = combine( _isReplying, _replyName, - _replyMessageId + _replyMessageId, ) { isReplying, replyName, replyMessageId -> Triple(isReplying, replyName, replyMessageId) } @@ -283,21 +259,21 @@ class ChatInputViewModel( val canTypeInConnectionState = deps.connectionState == ConnectionState.CONNECTED || !deps.autoDisableInput val inputState = when (deps.connectionState) { - ConnectionState.CONNECTED -> when { + ConnectionState.CONNECTED -> when { isWhisperTabActive && overlayState.whisperTarget != null -> InputState.Whispering - effectiveIsReplying -> InputState.Replying - overlayState.isAnnouncing -> InputState.Announcing - else -> InputState.Default + effectiveIsReplying -> InputState.Replying + overlayState.isAnnouncing -> InputState.Announcing + else -> InputState.Default } ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn - ConnectionState.DISCONNECTED -> InputState.Disconnected + ConnectionState.DISCONNECTED -> InputState.Disconnected } val enabled = when { isMentionsTabActive -> false - isWhisperTabActive -> deps.isLoggedIn && canTypeInConnectionState && overlayState.whisperTarget != null - else -> deps.isLoggedIn && canTypeInConnectionState + 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 @@ -305,9 +281,9 @@ class ChatInputViewModel( val effectiveReplyName = overlayState.replyName ?: (overlayState.sheetState as? FullScreenSheetState.Replies)?.replyName val overlay = when { overlayState.isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName) - isWhisperTabActive && overlayState.whisperTarget != null -> InputOverlay.Whisper(overlayState.whisperTarget) - overlayState.isAnnouncing -> InputOverlay.Announce - else -> InputOverlay.None + isWhisperTabActive && overlayState.whisperTarget != null -> InputOverlay.Whisper(overlayState.whisperTarget) + overlayState.isAnnouncing -> InputOverlay.Announce + else -> InputOverlay.None } ChatInputUiState( @@ -316,7 +292,7 @@ class ChatInputViewModel( enabled = enabled, hasLastMessage = when { isWhisperTabActive -> lastWhisperText != null - else -> chatRepository.getLastMessage() != null + else -> chatRepository.getLastMessage() != null }, suggestions = deps.suggestions.toImmutableList(), activeChannel = deps.activeChannel, @@ -344,8 +320,8 @@ class ChatInputViewModel( val isAnnouncing = _isAnnouncing.value val messageToSend = when { whisperTarget != null -> "/w ${whisperTarget.value} $text" - isAnnouncing -> "/announce $text" - else -> text + isAnnouncing -> "/announce $text" + else -> text } lastWhisperText = if (whisperTarget != null) text else null if (isAnnouncing) { @@ -361,14 +337,14 @@ class ChatInputViewModel( val chatState = fullScreenSheetState.value val replyIdOrNull = when { chatState is FullScreenSheetState.Replies -> chatState.replyMessageId - _isReplying.value -> _replyMessageId.value - else -> null + _isReplying.value -> _replyMessageId.value + else -> null } val commandResult = runCatching { when (chatState) { FullScreenSheetState.Whisper -> commandRepository.checkForWhisperCommand(message, skipSuspendingCommands) - else -> { + else -> { val roomState = channelRepository.getRoomState(channel) ?: return@launch val userState = userStateRepository.userState.value val shouldSkip = skipSuspendingCommands || chatState is FullScreenSheetState.Replies @@ -382,14 +358,15 @@ class ChatInputViewModel( when (commandResult) { is CommandResult.Accepted, - is CommandResult.Blocked -> Unit + is CommandResult.Blocked, + -> Unit - is CommandResult.IrcCommand -> { + is CommandResult.IrcCommand -> { chatRepository.sendMessage(message, replyIdOrNull, forceIrc = true) setReplying(false) } - is CommandResult.NotFound -> { + is CommandResult.NotFound -> { chatRepository.sendMessage(message, replyIdOrNull) setReplying(false) } @@ -399,14 +376,14 @@ class ChatInputViewModel( chatRepository.fakeWhisperIfNecessary(message) } val isWhisperContext = chatState is FullScreenSheetState.Whisper || - (chatState is FullScreenSheetState.Mention && _whisperTarget.value != null) + (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 -> { + is CommandResult.AcceptedWithResponse -> chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) + is CommandResult.Message -> { chatRepository.sendMessage(commandResult.message, replyIdOrNull) setReplying(false) } @@ -420,7 +397,7 @@ class ChatInputViewModel( fun getLastMessage() { val message = when { _whisperTarget.value != null -> lastWhisperText - else -> chatRepository.getLastMessage() + else -> chatRepository.getLastMessage() } ?: return textFieldState.edit { replace(0, length, message) @@ -484,17 +461,6 @@ class ChatInputViewModel( chatRepository.makeAndPostCustomSystemMessage(message, channel) } - fun updateInputText(text: String) { - textFieldState.edit { - replace(0, length, text) - placeCursorAtEnd() - } - } - - fun clearInput() { - textFieldState.clearText() - } - /** * Apply a suggestion to the current input text. * Replaces the current word with the suggestion and places cursor at the end. @@ -520,10 +486,6 @@ class ChatInputViewModel( } } - fun toggleEmoteMenu() { - _isEmoteMenuOpen.update { !it } - } - fun setEmoteMenuOpen(open: Boolean) { _isEmoteMenuOpen.value = open } @@ -557,3 +519,22 @@ internal fun computeSuggestionReplacement(text: String, cursorPos: Int, suggesti ) } +private data class UiDependencies( + val text: String, + val suggestions: List, + val activeChannel: UserName?, + val connectionState: ConnectionState, + val isLoggedIn: Boolean, + val autoDisableInput: Boolean, +) + +private data class InputOverlayState( + val sheetState: FullScreenSheetState, + val tab: Int, + val isReplying: Boolean, + val replyName: UserName?, + val replyMessageId: 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..ad55236b9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/InputActionConfig.kt @@ -0,0 +1,207 @@ +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.Shield +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VisibilityOff +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.Default.Videocam + InputAction.ModActions -> Icons.Default.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/TourOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/TourOverlay.kt new file mode 100644 index 000000000..a28d087e3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/TourOverlay.kt @@ -0,0 +1,98 @@ +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, +) { + 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 = 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) + } +} From 9c417db69cdec60a70af4a422a14a1c2a4ce879e Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 23:04:54 +0200 Subject: [PATCH 167/349] build: Migrate ktfmt from broken Gradle plugin to standalone binary --- .editorconfig | 6 ++++-- .github/workflows/android.yml | 4 ++-- app/build.gradle.kts | 21 +++++++++++++++++---- app/config/detekt.yml | 2 +- build.gradle.kts | 2 +- gradle/libs.versions.toml | 5 +++-- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.editorconfig b/.editorconfig index 45d63e8e1..edbf56e5e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,7 +4,7 @@ end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true -max_line_length = 250 +max_line_length = 200 tab_width = 4 ij_continuation_indent_size = 8 ij_formatter_off_tag = @formatter:off @@ -40,7 +40,9 @@ ij_xml_text_wrap = normal ij_xml_use_custom_settings = true [{*.kt,*.kts,*.main.kts}] -# ktfmt kotlinlang-style compatible settings +# 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 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0782738cf..410016e71 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -44,8 +44,8 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: ktfmt check - run: bash ./gradlew :app:ktfmtCheck + - name: Spotless check + run: bash ./gradlew :app:spotlessCheck - name: Detekt run: bash ./gradlew :app:detekt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94499a795..608da57f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.about.libraries.android) alias(libs.plugins.android.junit5) - alias(libs.plugins.ktfmt) + alias(libs.plugins.spotless) alias(libs.plugins.detekt) } @@ -233,9 +233,22 @@ junitPlatform { } } -ktfmt { - kotlinLangStyle() - maxWidth.set(250) +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", + )) + } + kotlinGradle { + target("*.gradle.kts") + ktlint(libs.versions.ktlint.get()) + } } detekt { diff --git a/app/config/detekt.yml b/app/config/detekt.yml index 7116e2243..f541dd9f3 100644 --- a/app/config/detekt.yml +++ b/app/config/detekt.yml @@ -32,7 +32,7 @@ style: MagicNumber: active: false MaxLineLength: - maxLineLength: 250 + maxLineLength: 200 excludeCommentStatements: true ReturnCount: max: 8 diff --git a/build.gradle.kts b/build.gradle.kts index 013144759..52468c272 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,6 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.about.libraries.android) apply false alias(libs.plugins.android.junit5) apply false - alias(libs.plugins.ktfmt) apply false + alias(libs.plugins.spotless) apply false alias(libs.plugins.detekt) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2fd9f4ddc..fe24b8622 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,8 @@ processPhoenix = "3.0.0" colorPicker = "3.1.0" reorderable = "2.4.3" -ktfmt = "0.26.0" +spotless = "8.4.0" +ktlint = "1.8.0" detekt = "1.23.8" composeRules = "0.4.23" @@ -156,6 +157,6 @@ nav-safeargs-kotlin = { id = "androidx.navigation.safeargs.kotlin", version.ref ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } about-libraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "about-libraries" } android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJunit5" } -ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } androidx-room = { id = "androidx.room", version.ref = "androidxRoom" } #TODO use me when working From fa7b69154d869e782a647e9f19f46c5ec631b476 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 29 Mar 2026 23:45:51 +0200 Subject: [PATCH 168/349] style: Apply Spotless + ktlint formatting to entire codebase --- app/config/detekt.yml | 5 +- .../com/flxrs/dankchat/DankChatApplication.kt | 47 +- .../com/flxrs/dankchat/DankChatViewModel.kt | 23 +- .../com/flxrs/dankchat/data/DisplayName.kt | 1 + .../kotlin/com/flxrs/dankchat/data/UserId.kt | 5 +- .../com/flxrs/dankchat/data/UserName.kt | 15 +- .../flxrs/dankchat/data/api/ApiException.kt | 14 +- .../flxrs/dankchat/data/api/auth/AuthApi.kt | 6 +- .../dankchat/data/api/auth/AuthApiClient.kt | 88 +- .../dankchat/data/api/auth/dto/ValidateDto.kt | 3 +- .../data/api/auth/dto/ValidateErrorDto.kt | 5 +- .../dankchat/data/api/badges/BadgesApi.kt | 1 - .../data/api/badges/BadgesApiClient.kt | 7 +- .../flxrs/dankchat/data/api/bttv/BTTVApi.kt | 1 - .../dankchat/data/api/bttv/BTTVApiClient.kt | 7 +- .../data/api/bttv/dto/BTTVChannelDto.kt | 2 +- .../data/api/bttv/dto/BTTVEmoteDto.kt | 7 +- .../data/api/bttv/dto/BTTVGlobalEmoteDto.kt | 5 +- .../dankchat/data/api/dankchat/DankChatApi.kt | 1 - .../data/api/dankchat/DankChatApiClient.kt | 7 +- .../data/api/dankchat/dto/DankChatBadgeDto.kt | 6 +- .../api/dankchat/dto/DankChatEmoteSetDto.kt | 2 +- .../data/api/eventapi/EventSubClient.kt | 205 +++-- .../data/api/eventapi/EventSubClientState.kt | 3 + .../data/api/eventapi/EventSubManager.kt | 29 +- .../data/api/eventapi/EventSubMessage.kt | 43 +- .../data/api/eventapi/EventSubTopic.kt | 61 +- .../dto/EventSubSubscriptionRequestDto.kt | 7 +- .../dto/messages/KeepAliveMessageDto.kt | 5 +- .../dto/messages/NotificationMessageDto.kt | 10 +- .../dto/messages/ReconnectMessageDto.kt | 9 +- .../dto/messages/RevocationMessageDto.kt | 9 +- .../dto/messages/WelcomeMessageDto.kt | 9 +- .../notification/AutomodMessageDto.kt | 10 +- .../com/flxrs/dankchat/data/api/ffz/FFZApi.kt | 1 - .../dankchat/data/api/ffz/FFZApiClient.kt | 7 +- .../data/api/ffz/dto/FFZChannelDto.kt | 5 +- .../dankchat/data/api/ffz/dto/FFZEmoteDto.kt | 8 +- .../dankchat/data/api/ffz/dto/FFZGlobalDto.kt | 5 +- .../dankchat/data/api/ffz/dto/FFZRoomDto.kt | 5 +- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 1 - .../dankchat/data/api/helix/HelixApiClient.kt | 307 ++++--- .../data/api/helix/HelixApiException.kt | 40 +- .../data/api/helix/dto/AnnouncementColor.kt | 2 +- .../data/api/helix/dto/CheermoteDto.kt | 24 +- .../data/api/helix/dto/CommercialDto.kt | 6 +- .../data/api/helix/dto/HelixErrorDto.kt | 5 +- .../dankchat/data/api/helix/dto/MarkerDto.kt | 7 +- .../data/api/helix/dto/MarkerRequestDto.kt | 5 +- .../dankchat/data/api/helix/dto/ModVipDto.kt | 6 +- .../data/api/helix/dto/SendChatMessageDto.kt | 11 +- .../api/helix/dto/ShieldModeRequestDto.kt | 4 +- .../data/api/helix/dto/UserBlockDto.kt | 4 +- .../dankchat/data/api/helix/dto/UserDto.kt | 2 +- .../data/api/helix/dto/UserFollowsDataDto.kt | 4 +- .../data/api/helix/dto/UserFollowsDto.kt | 5 +- .../api/recentmessages/RecentMessagesApi.kt | 1 - .../recentmessages/RecentMessagesApiClient.kt | 20 +- .../RecentMessagesApiException.kt | 4 +- .../dankchat/data/api/seventv/SevenTVApi.kt | 1 - .../data/api/seventv/SevenTVApiClient.kt | 10 +- .../api/seventv/dto/SevenTVEmoteDataDto.kt | 1 - .../data/api/seventv/dto/SevenTVEmoteDto.kt | 7 +- .../api/seventv/dto/SevenTVEmoteFileDto.kt | 5 +- .../api/seventv/dto/SevenTVEmoteHostDto.kt | 5 +- .../api/seventv/dto/SevenTVEmoteSetDto.kt | 6 +- .../data/api/seventv/dto/SevenTVUserDto.kt | 6 +- .../seventv/eventapi/SevenTVEventApiClient.kt | 162 ++-- .../seventv/eventapi/SevenTVEventMessage.kt | 11 +- .../seventv/eventapi/dto/DispatchMessage.kt | 23 +- .../api/seventv/eventapi/dto/HelloMessage.kt | 5 +- .../seventv/eventapi/dto/SubscribeRequest.kt | 5 +- .../eventapi/dto/UnsubscribeRequest.kt | 4 +- .../dankchat/data/api/supibot/SupibotApi.kt | 1 - .../data/api/supibot/SupibotApiClient.kt | 10 +- .../api/supibot/dto/SupibotCommandsDto.kt | 1 - .../dankchat/data/api/upload/UploadClient.kt | 55 +- .../dankchat/data/api/upload/dto/UploadDto.kt | 6 +- .../flxrs/dankchat/data/auth/AuthDataStore.kt | 110 ++- .../data/auth/AuthStateCoordinator.kt | 60 +- .../data/auth/StartupValidationHolder.kt | 3 + .../dankchat/data/chat/ChatImportance.kt | 2 +- .../data/database/DankChatDatabase.kt | 21 +- .../database/converter/InstantConverter.kt | 1 - .../data/database/dao/BadgeHighlightDao.kt | 1 - .../data/database/dao/EmoteUsageDao.kt | 1 - .../data/database/dao/MessageHighlightDao.kt | 1 - .../data/database/dao/MessageIgnoreDao.kt | 1 - .../data/database/dao/RecentUploadsDao.kt | 1 - .../data/database/dao/UserDisplayDao.kt | 1 - .../data/database/dao/UserHighlightDao.kt | 1 - .../data/database/dao/UserIgnoreDao.kt | 1 - .../database/entity/BadgeHighlightEntity.kt | 4 +- .../database/entity/BlacklistedUserEntity.kt | 4 +- .../data/database/entity/EmoteUsageEntity.kt | 1 - .../database/entity/MessageHighlightEntity.kt | 16 +- .../database/entity/MessageIgnoreEntity.kt | 14 +- .../data/database/entity/UploadEntity.kt | 4 +- .../data/database/entity/UserDisplayEntity.kt | 2 +- .../database/entity/UserHighlightEntity.kt | 4 +- .../data/database/entity/UserIgnoreEntity.kt | 10 +- .../dankchat/data/debug/ApiDebugSection.kt | 25 +- .../dankchat/data/debug/AppDebugSection.kt | 15 +- .../dankchat/data/debug/AuthDebugSection.kt | 29 +- .../dankchat/data/debug/BuildDebugSection.kt | 8 +- .../data/debug/ChannelDebugSection.kt | 41 +- .../data/debug/ConnectionDebugSection.kt | 46 +- .../flxrs/dankchat/data/debug/DebugSection.kt | 1 + .../data/debug/DebugSectionRegistry.kt | 1 - .../dankchat/data/debug/EmoteDebugSection.kt | 72 +- .../dankchat/data/debug/ErrorsDebugSection.kt | 17 +- .../dankchat/data/debug/RulesDebugSection.kt | 43 +- .../data/debug/SessionDebugSection.kt | 28 +- .../dankchat/data/debug/StreamDebugSection.kt | 25 +- .../data/debug/UserStateDebugSection.kt | 24 +- .../com/flxrs/dankchat/data/irc/IrcMessage.kt | 43 +- .../data/notification/NotificationData.kt | 31 +- .../data/notification/NotificationService.kt | 262 +++--- .../data/repo/HighlightsRepository.kt | 324 +++---- .../dankchat/data/repo/IgnoresRepository.kt | 171 ++-- .../data/repo/RecentUploadsRepository.kt | 18 +- .../dankchat/data/repo/RepliesRepository.kt | 67 +- .../data/repo/UserDisplayRepository.kt | 55 +- .../dankchat/data/repo/channel/Channel.kt | 7 +- .../data/repo/channel/ChannelRepository.kt | 78 +- .../data/repo/chat/ChatChannelProvider.kt | 1 - .../dankchat/data/repo/chat/ChatConnector.kt | 4 +- .../data/repo/chat/ChatEventProcessor.kt | 414 +++++---- .../data/repo/chat/ChatMessageRepository.kt | 54 +- .../data/repo/chat/ChatMessageSender.kt | 87 +- .../repo/chat/ChatNotificationRepository.kt | 32 +- .../dankchat/data/repo/chat/ChatRepository.kt | 35 +- .../data/repo/chat/MessageProcessor.kt | 63 +- .../data/repo/chat/RecentMessagesHandler.kt | 101 ++- .../dankchat/data/repo/chat/UserState.kt | 3 +- .../data/repo/chat/UserStateRepository.kt | 43 +- .../data/repo/chat/UsersRepository.kt | 1 + .../dankchat/data/repo/command/Command.kt | 4 +- .../data/repo/command/CommandRepository.kt | 119 +-- .../data/repo/command/CommandResult.kt | 6 + .../dankchat/data/repo/data/DataRepository.kt | 62 +- .../data/repo/data/DataUpdateEventMessage.kt | 1 + .../data/repo/emote/EmojiRepository.kt | 7 +- .../data/repo/emote/EmoteRepository.kt | 716 ++++++++------- .../data/repo/emote/EmoteUsageRepository.kt | 24 +- .../flxrs/dankchat/data/repo/emote/Emotes.kt | 24 +- .../dankchat/data/repo/stream/StreamData.kt | 8 +- .../data/repo/stream/StreamDataRepository.kt | 39 +- .../data/state/ChannelLoadingState.kt | 56 +- .../dankchat/data/state/DataLoadingState.kt | 11 +- .../dankchat/data/state/ImageUploadState.kt | 4 +- .../flxrs/dankchat/data/twitch/badge/Badge.kt | 5 + .../dankchat/data/twitch/badge/BadgeSet.kt | 47 +- .../dankchat/data/twitch/badge/BadgeType.kt | 14 +- .../data/twitch/chat/ChatConnection.kt | 216 +++-- .../data/twitch/command/CommandContext.kt | 9 +- .../data/twitch/command/TwitchCommand.kt | 3 +- .../twitch/command/TwitchCommandRepository.kt | 615 ++++++++----- .../data/twitch/emote/ChatMessageEmoteType.kt | 13 +- .../data/twitch/emote/CheermoteSet.kt | 13 +- .../dankchat/data/twitch/emote/EmoteType.kt | 16 +- .../data/twitch/emote/GenericEmote.kt | 19 +- .../data/twitch/emote/ThirdPartyEmoteType.kt | 10 +- .../data/twitch/message/AutomodMessage.kt | 1 - .../data/twitch/message/HighlightState.kt | 7 +- .../dankchat/data/twitch/message/Message.kt | 27 +- .../data/twitch/message/MessageThread.kt | 7 +- .../twitch/message/MessageThreadHeader.kt | 7 +- .../data/twitch/message/ModerationMessage.kt | 484 ++++++---- .../data/twitch/message/NoticeMessage.kt | 54 +- .../twitch/message/PointRedemptionMessage.kt | 3 +- .../data/twitch/message/PrivMessage.kt | 47 +- .../dankchat/data/twitch/message/RoomState.kt | 61 +- .../data/twitch/message/RoomStateTag.kt | 18 +- .../data/twitch/message/SystemMessageType.kt | 28 + .../data/twitch/message/UserDisplay.kt | 2 - .../data/twitch/message/UserNoticeMessage.kt | 60 +- .../data/twitch/message/WhisperMessage.kt | 28 +- .../data/twitch/pubsub/PubSubConnection.kt | 38 +- .../data/twitch/pubsub/PubSubEvent.kt | 3 + .../data/twitch/pubsub/PubSubManager.kt | 45 +- .../data/twitch/pubsub/PubSubMessage.kt | 13 +- .../data/twitch/pubsub/PubSubTopic.kt | 2 + .../twitch/pubsub/dto/PubSubDataMessage.kt | 5 +- .../pubsub/dto/PubSubDataObjectMessage.kt | 5 +- .../pubsub/dto/redemption/PointRedemption.kt | 5 +- .../dto/redemption/PointRedemptionData.kt | 6 +- .../dto/redemption/PointRedemptionImages.kt | 6 +- .../dto/redemption/PointRedemptionUser.kt | 6 +- .../pubsub/dto/whisper/WhisperDataBadge.kt | 5 +- .../pubsub/dto/whisper/WhisperDataEmote.kt | 6 +- .../dto/whisper/WhisperDataRecipient.kt | 7 +- .../com/flxrs/dankchat/di/ConnectionModule.kt | 16 +- .../com/flxrs/dankchat/di/DatabaseModule.kt | 41 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 136 +-- .../dankchat/domain/ChannelDataCoordinator.kt | 214 +++-- .../dankchat/domain/ChannelDataLoader.kt | 93 +- .../dankchat/domain/ConnectionCoordinator.kt | 8 +- .../dankchat/domain/GetChannelsUseCase.kt | 39 +- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 33 +- .../preferences/DankChatPreferenceStore.kt | 52 +- .../dankchat/preferences/about/AboutScreen.kt | 8 +- .../appearance/AppearanceSettings.kt | 19 +- .../appearance/AppearanceSettingsDataStore.kt | 141 +-- .../appearance/AppearanceSettingsScreen.kt | 52 +- .../appearance/AppearanceSettingsState.kt | 12 +- .../appearance/AppearanceSettingsViewModel.kt | 36 +- .../dankchat/preferences/chat/ChatSettings.kt | 1 - .../preferences/chat/ChatSettingsDataStore.kt | 274 +++--- .../preferences/chat/ChatSettingsScreen.kt | 34 +- .../preferences/chat/ChatSettingsState.kt | 17 + .../preferences/chat/ChatSettingsViewModel.kt | 103 ++- .../chat/commands/CommandsScreen.kt | 21 +- .../chat/commands/CommandsViewModel.kt | 15 +- .../chat/userdisplay/UserDisplayEvent.kt | 1 + .../chat/userdisplay/UserDisplayItem.kt | 7 +- .../chat/userdisplay/UserDisplayScreen.kt | 21 +- .../chat/userdisplay/UserDisplayViewModel.kt | 5 +- .../components/CheckboxWithText.kt | 8 +- .../components/PreferenceCategory.kt | 14 +- .../preferences/components/PreferenceItem.kt | 46 +- .../components/PreferenceSummary.kt | 4 +- .../components/PreferenceTabRow.kt | 7 +- .../developer/DeveloperSettings.kt | 1 - .../developer/DeveloperSettingsDataStore.kt | 51 +- .../developer/DeveloperSettingsScreen.kt | 51 +- .../developer/DeveloperSettingsState.kt | 11 + .../developer/DeveloperSettingsViewModel.kt | 57 +- .../developer/customlogin/CustomLoginState.kt | 11 +- .../customlogin/CustomLoginViewModel.kt | 54 +- .../notifications/NotificationsSettings.kt | 8 +- .../NotificationsSettingsDataStore.kt | 71 +- .../NotificationsSettingsScreen.kt | 23 +- .../NotificationsSettingsViewModel.kt | 24 +- .../highlights/HighlightEvent.kt | 1 + .../notifications/highlights/HighlightItem.kt | 47 +- .../highlights/HighlightsScreen.kt | 126 +-- .../highlights/HighlightsViewModel.kt | 19 +- .../notifications/ignores/IgnoreEvent.kt | 3 + .../notifications/ignores/IgnoreItem.kt | 47 +- .../notifications/ignores/IgnoresScreen.kt | 74 +- .../notifications/ignores/IgnoresTab.kt | 2 +- .../notifications/ignores/IgnoresViewModel.kt | 25 +- .../overview/OverviewSettingsScreen.kt | 10 +- .../preferences/overview/SecretDankerMode.kt | 2 +- .../stream/StreamsSettingsDataStore.kt | 70 +- .../stream/StreamsSettingsScreen.kt | 18 +- .../stream/StreamsSettingsViewModel.kt | 28 +- .../preferences/tools/ToolsSettings.kt | 51 +- .../tools/ToolsSettingsDataStore.kt | 163 ++-- .../preferences/tools/ToolsSettingsScreen.kt | 29 +- .../preferences/tools/ToolsSettingsState.kt | 6 + .../tools/ToolsSettingsViewModel.kt | 39 +- .../tools/tts/TTSUserIgnoreListScreen.kt | 21 +- .../tools/tts/TTSUserIgnoreListViewModel.kt | 20 +- .../tools/upload/ImageUploaderScreen.kt | 15 +- .../tools/upload/ImageUploaderViewModel.kt | 29 +- .../tools/upload/RecentUploadsViewModel.kt | 46 +- .../dankchat/ui/changelog/ChangelogScreen.kt | 14 +- .../ui/changelog/ChangelogSheetViewModel.kt | 12 +- .../dankchat/ui/changelog/DankChatVersion.kt | 21 +- .../dankchat/ui/chat/AdaptiveTextColor.kt | 4 +- .../flxrs/dankchat/ui/chat/BackgroundColor.kt | 4 +- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 4 +- .../dankchat/ui/chat/ChatMessageMapper.kt | 828 ++++++++++-------- .../flxrs/dankchat/ui/chat/ChatMessageText.kt | 14 +- .../dankchat/ui/chat/ChatMessageUiState.kt | 6 +- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 90 +- .../dankchat/ui/chat/ChatScrollBehavior.kt | 21 +- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 155 ++-- .../ui/chat/EmoteAnimationCoordinator.kt | 26 +- .../dankchat/ui/chat/EmoteDrawablePainter.kt | 26 +- .../flxrs/dankchat/ui/chat/EmoteScaling.kt | 42 +- .../flxrs/dankchat/ui/chat/Linkification.kt | 9 +- .../flxrs/dankchat/ui/chat/StackedEmote.kt | 29 +- .../ui/chat/TextWithMeasuredInlineContent.kt | 36 +- .../ui/chat/emote/EmoteInfoViewModel.kt | 98 +-- .../dankchat/ui/chat/emotemenu/EmoteItem.kt | 13 +- .../ui/chat/emotemenu/EmoteMenuTab.kt | 2 +- .../chat/history/MessageHistoryViewModel.kt | 142 +-- .../ui/chat/mention/MentionComposable.kt | 4 +- .../ui/chat/mention/MentionViewModel.kt | 108 +-- .../ui/chat/message/MessageOptionsState.kt | 2 + .../chat/message/MessageOptionsViewModel.kt | 95 +- .../ui/chat/messages/AutomodMessage.kt | 24 +- .../dankchat/ui/chat/messages/PrivMessage.kt | 30 +- .../ui/chat/messages/SystemMessages.kt | 47 +- .../ui/chat/messages/WhisperAndRedemption.kt | 39 +- .../ui/chat/messages/common/InlineContent.kt | 29 +- .../messages/common/MessageTextBuilders.kt | 91 +- .../messages/common/MessageTextRenderer.kt | 4 +- .../messages/common/SimpleMessageContainer.kt | 14 +- .../ui/chat/replies/RepliesComposable.kt | 4 +- .../dankchat/ui/chat/replies/RepliesState.kt | 2 + .../ui/chat/replies/RepliesViewModel.kt | 82 +- .../dankchat/ui/chat/search/ChatItemFilter.kt | 76 +- .../ui/chat/search/ChatSearchFilter.kt | 4 + .../ui/chat/search/ChatSearchFilterParser.kt | 40 +- .../ui/chat/search/SearchFilterSuggestions.kt | 70 +- .../ui/chat/suggestion/SuggestionProvider.kt | 126 +-- .../dankchat/ui/chat/user/UserPopupDialog.kt | 54 +- .../dankchat/ui/chat/user/UserPopupState.kt | 4 +- .../ui/chat/user/UserPopupViewModel.kt | 49 +- .../flxrs/dankchat/ui/login/LoginScreen.kt | 13 +- .../flxrs/dankchat/ui/login/LoginViewModel.kt | 30 +- .../flxrs/dankchat/ui/main/DraggableHandle.kt | 15 +- .../dankchat/ui/main/EmptyStateContent.kt | 7 +- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 88 +- .../com/flxrs/dankchat/ui/main/InputState.kt | 5 + .../flxrs/dankchat/ui/main/MainActivity.kt | 203 +++-- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 99 ++- .../com/flxrs/dankchat/ui/main/MainEvent.kt | 9 + .../com/flxrs/dankchat/ui/main/MainScreen.kt | 35 +- .../dankchat/ui/main/MainScreenComponents.kt | 36 +- .../ui/main/MainScreenEventHandler.kt | 24 +- .../ui/main/MainScreenPagerContent.kt | 31 +- .../dankchat/ui/main/MainScreenViewModel.kt | 10 +- .../dankchat/ui/main/QuickActionsMenu.kt | 56 +- .../dankchat/ui/main/StreamToolbarState.kt | 12 +- .../flxrs/dankchat/ui/main/ToolbarAction.kt | 17 + .../channel/ChannelManagementViewModel.kt | 8 +- .../ui/main/channel/ChannelPagerViewModel.kt | 29 +- .../ui/main/channel/ChannelTabUiState.kt | 15 +- .../ui/main/channel/ChannelTabViewModel.kt | 76 +- .../ui/main/dialog/AddChannelDialog.kt | 8 +- .../ui/main/dialog/ConfirmationDialog.kt | 8 +- .../ui/main/dialog/DialogStateViewModel.kt | 13 +- .../ui/main/dialog/EmoteInfoDialog.kt | 45 +- .../ui/main/dialog/MainScreenDialogs.kt | 28 +- .../ui/main/dialog/ManageChannelsDialog.kt | 44 +- .../ui/main/dialog/MessageOptionsDialog.kt | 72 +- .../ui/main/dialog/ModActionsDialog.kt | 77 +- .../ui/main/dialog/ModActionsViewModel.kt | 4 +- .../dankchat/ui/main/input/ChatBottomBar.kt | 13 +- .../dankchat/ui/main/input/ChatInputLayout.kt | 134 ++- .../ui/main/input/ChatInputUiState.kt | 8 +- .../ui/main/input/ChatInputViewModel.kt | 27 +- .../ui/main/input/InputActionConfig.kt | 29 +- .../ui/main/input/SuggestionDropdown.kt | 52 +- .../dankchat/ui/main/input/TourOverlay.kt | 18 +- .../dankchat/ui/main/sheet/DebugInfoSheet.kt | 8 +- .../ui/main/sheet/DebugInfoViewModel.kt | 13 +- .../flxrs/dankchat/ui/main/sheet/EmoteMenu.kt | 43 +- .../dankchat/ui/main/sheet/EmoteMenuSheet.kt | 37 +- .../ui/main/sheet/EmoteMenuViewModel.kt | 84 +- .../ui/main/sheet/FullScreenSheetOverlay.kt | 29 +- .../dankchat/ui/main/sheet/MentionSheet.kt | 18 +- .../ui/main/sheet/MessageHistorySheet.kt | 11 +- .../dankchat/ui/main/sheet/RepliesSheet.kt | 18 +- .../ui/main/sheet/SheetNavigationState.kt | 11 +- .../ui/main/sheet/SheetNavigationViewModel.kt | 24 +- .../dankchat/ui/main/stream/StreamView.kt | 35 +- .../ui/main/stream/StreamViewModel.kt | 7 +- .../dankchat/ui/main/stream/StreamWebView.kt | 34 +- .../ui/onboarding/OnboardingDataStore.kt | 50 +- .../ui/onboarding/OnboardingScreen.kt | 56 +- .../ui/onboarding/OnboardingViewModel.kt | 19 +- .../dankchat/ui/share/ShareUploadActivity.kt | 12 +- .../flxrs/dankchat/ui/theme/DankChatTheme.kt | 19 +- .../dankchat/ui/tour/FeatureTourViewModel.kt | 51 +- .../dankchat/utils/AppLifecycleListener.kt | 5 + .../com/flxrs/dankchat/utils/DateTimeUtils.kt | 33 +- .../dankchat/utils/GetImageOrVideoContract.kt | 12 +- .../flxrs/dankchat/utils/IntRangeParceler.kt | 1 - .../com/flxrs/dankchat/utils/MediaUtils.kt | 72 +- .../com/flxrs/dankchat/utils/TextResource.kt | 29 +- .../utils/compose/BottomSheetNestedScroll.kt | 12 +- .../dankchat/utils/compose/ContentAlpha.kt | 11 +- .../dankchat/utils/compose/InfoBottomSheet.kt | 11 +- .../utils/compose/RoundedCornerPadding.kt | 116 ++- .../utils/compose/StyledBottomSheet.kt | 7 +- .../dankchat/utils/compose/SwipeToDelete.kt | 8 +- .../utils/compose/animatedAppBarColor.kt | 2 +- .../utils/compose/buildLinkAnnotation.kt | 24 +- .../datastore/DataStoreKotlinxSerializer.kt | 18 +- .../utils/datastore/DataStoreUtils.kt | 39 +- .../dankchat/utils/datastore/Migration.kt | 18 +- .../utils/extensions/BottomSheetExtensions.kt | 17 +- .../utils/extensions/ChatListOperations.kt | 19 +- .../utils/extensions/CollectionExtensions.kt | 4 +- .../utils/extensions/ColorExtensions.kt | 8 +- .../utils/extensions/CoroutineExtensions.kt | 32 +- .../dankchat/utils/extensions/Extensions.kt | 19 +- .../utils/extensions/FlowExtensions.kt | 53 +- .../utils/extensions/ModerationOperations.kt | 25 +- .../utils/extensions/StringExtensions.kt | 31 +- .../extensions/SystemMessageOperations.kt | 8 +- .../flxrs/dankchat/data/irc/IrcMessageTest.kt | 2 - .../data/repo/emote/EmoteRepositoryTest.kt | 34 +- .../data/twitch/chat/MockIrcServer.kt | 26 +- .../twitch/chat/TwitchIrcIntegrationTest.kt | 5 +- .../domain/ChannelDataCoordinatorTest.kt | 65 +- .../dankchat/domain/ChannelDataLoaderTest.kt | 44 +- .../suggestion/SuggestionFilteringTest.kt | 48 +- .../SuggestionProviderExtractWordTest.kt | 16 +- .../chat/suggestion/SuggestionScoringTest.kt | 16 +- .../ui/main/SuggestionReplacementTest.kt | 3 +- .../ui/tour/FeatureTourViewModelTest.kt | 1 - .../flxrs/dankchat/utils/DateTimeUtilsTest.kt | 1 - 399 files changed, 7839 insertions(+), 7409 deletions(-) diff --git a/app/config/detekt.yml b/app/config/detekt.yml index f541dd9f3..76a1024e4 100644 --- a/app/config/detekt.yml +++ b/app/config/detekt.yml @@ -13,6 +13,8 @@ complexity: active: false NestedBlockDepth: active: false + LargeClass: + active: false ComplexCondition: threshold: 5 @@ -32,8 +34,9 @@ style: MagicNumber: active: false MaxLineLength: - maxLineLength: 200 + maxLineLength: 210 excludeCommentStatements: true + excludeRawStrings: true ReturnCount: max: 8 ForbiddenComment: diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 54bf08b13..b0f7a5a26 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -32,7 +32,9 @@ import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import org.koin.ksp.generated.module -class DankChatApplication : Application(), SingletonImageLoader.Factory { +class DankChatApplication : + Application(), + SingletonImageLoader.Factory { // Dummy comment to force KSP re-run private val dispatchersProvider: DispatchersProvider by inject() @@ -66,36 +68,37 @@ class DankChatApplication : Application(), SingletonImageLoader.Factory { private suspend fun setupThemeMode() { val theme = appearanceSettingsDataStore.settings.first().theme - val nightMode = when { - theme == Dark -> AppCompatDelegate.MODE_NIGHT_YES - theme == System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - else -> AppCompatDelegate.MODE_NIGHT_NO - } + val nightMode = + when { + theme == Dark -> AppCompatDelegate.MODE_NIGHT_YES + theme == 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 { - // minSdk 30 guarantees AnimatedImageDecoder support (API 28+) - add(AnimatedImageDecoder.Factory()) - 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 ec976fa95..801fde755 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -24,21 +24,22 @@ class DankChatViewModel( private val chatChannelProvider: ChatChannelProvider, private val authStateCoordinator: AuthStateCoordinator, ) : ViewModel() { - val serviceEvents = dataRepository.serviceEvents val activeChannel = chatChannelProvider.activeChannel - val isLoggedIn: Flow = authDataStore.settings - .map { it.isLoggedIn } - .distinctUntilChanged() + val isLoggedIn: Flow = + authDataStore.settings + .map { it.isLoggedIn } + .distinctUntilChanged() 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, - ) + val keepScreenOn = + appearanceSettingsDataStore.settings + .map { it.keepScreenOn } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = appearanceSettingsDataStore.current().keepScreenOn, + ) fun checkLogin() { if (authDataStore.isLoggedIn && authDataStore.oAuthKey.isNullOrBlank()) { 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 8c5d428a2..843b1cdda 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt @@ -12,4 +12,5 @@ value class DisplayName(val value: String) : Parcelable { } fun DisplayName.toUserName() = UserName(value) + 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 d0da9e237..5a6b857b4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt @@ -12,6 +12,5 @@ value class UserId(val value: String) : Parcelable { } fun String.toUserId() = UserId(this) -inline fun UserId.ifBlank(default: () -> UserId?): UserId? { - return if (value.isBlank()) default() else this -} + +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..49656bd15 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt @@ -8,24 +8,26 @@ import kotlinx.serialization.Serializable @Serializable @Parcelize 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: 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 +36,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..f3ec3e0d9 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 @@ -11,16 +11,8 @@ import io.ktor.http.isSuccess import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -open class ApiException( - open val status: HttpStatusCode, - open val url: Url?, - override val message: String?, - override val cause: Throwable? = null -) : Throwable(message, cause) { - - override fun toString(): String { - return "ApiException(status=$status, url=$url, message=$message, cause=$cause)" - } +open class ApiException(open val status: HttpStatusCode, open val url: Url?, override val message: String?, override val cause: Throwable? = null) : Throwable(message, 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 +38,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 } } 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 bf3fc804e..f065ec114 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 @@ -7,16 +7,16 @@ import io.ktor.client.request.get import io.ktor.http.parameters class AuthApi(private val ktorClient: HttpClient) { - suspend fun validateUser(token: String) = ktorClient.get("validate") { bearerAuth(token) } suspend fun revokeToken(token: String, clientId: String) = ktorClient.submitForm( url = "revoke", - formParameters = parameters { + 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 57845b3c2..f06e0c05d 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 @@ -14,12 +14,14 @@ import org.koin.core.annotation.Single @Single 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) } @@ -31,50 +33,52 @@ class AuthApiClient(private val authApi: AuthApi, private val json: Json) { } 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", - "user:read:chat", - "user:read:emotes", - "user:write:chat", - "whispers:edit", - "whispers:read", - ) + 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 e335672cd..6b15ec668 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 @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class ValidateErrorDto( - val status: Int, - val message: String -) +data class ValidateErrorDto(val status: Int, 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 ad885601e..ddff0abb7 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 @@ -5,7 +5,6 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get class BadgesApi(private val ktorClient: HttpClient) { - suspend fun getGlobalBadges() = ktorClient.get("global/display") suspend fun getChannelBadges(channelId: UserId) = ktorClient.get("channels/$channelId/display") 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..65d013d10 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 @@ -10,15 +10,16 @@ import org.koin.core.annotation.Single @Single 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/bttv/BTTVApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApi.kt index 5cd38d9c5..9de4f5ee3 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 @@ -5,7 +5,6 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get class BTTVApi(private val ktorClient: HttpClient) { - suspend fun getChannelEmotes(channelId: UserId) = ktorClient.get("users/twitch/$channelId") suspend fun getGlobalEmotes() = ktorClient.get("emotes/global") 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..9b5cfd9b6 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 @@ -11,15 +11,16 @@ import org.koin.core.annotation.Single @Single 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 65ef15796..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 + @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..c9d3cdf5e 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 @@ -5,9 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class BTTVEmoteDto( - val id: String, - val code: String, - val user: BTTVEmoteUserDto?, -) - +data class BTTVEmoteDto(val id: String, val code: String, val user: BTTVEmoteUserDto?) 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 15781970d..d5eb84270 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 @@ -6,7 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class BTTVGlobalEmoteDto( - @SerialName(value = "id") val id: String, - @SerialName(value = "code") val code: String, -) +data class BTTVGlobalEmoteDto(@SerialName(value = "id") val id: String, @SerialName(value = "code") val code: String) 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 634899878..231dd943b 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 @@ -5,7 +5,6 @@ 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) } 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..7161bc24b 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 @@ -9,15 +9,16 @@ 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 = ",")) + dankChatApi + .getSets(sets.joinToString(separator = ",")) .throwApiErrorOnFailure(json) .body() } 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 ce727ae77..7b65a058a 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 @@ -7,8 +7,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class DankChatBadgeDto( - @SerialName(value = "type") val type: String, - @SerialName(value = "url") val url: String, - @SerialName(value = "users") val users: List -) +data class DankChatBadgeDto(@SerialName(value = "type") val type: String, @SerialName(value = "url") val url: String, @SerialName(value = "users") val users: List) 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 index 370a4ac8e..49e998d59 100644 --- 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 @@ -13,5 +13,5 @@ data class DankChatEmoteSetDto( @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? + @SerialName(value = "emotes") val emotes: List?, ) 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 d0d6ba311..4048d8c11 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 @@ -53,13 +53,7 @@ import kotlin.time.Duration.Companion.seconds @OptIn(DelicateCoroutinesApi::class) @Single -class EventSubClient( - private val helixApiClient: HelixApiClient, - private val json: Json, - httpClient: HttpClient, - dispatchersProvider: DispatchersProvider, -) { - +class EventSubClient(private val helixApiClient: HelixApiClient, private val json: Json, httpClient: HttpClient, dispatchersProvider: DispatchersProvider) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.io) private var session: DefaultClientWebSocketSession? = null private var previousSession: DefaultClientWebSocketSession? = null @@ -70,9 +64,10 @@ 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() @@ -96,34 +91,40 @@ class EventSubClient( session = this while (isActive) { val result = incoming.receiveCatching() - val raw = when (val element = result.getOrNull()) { - null -> { - val cause = result.exceptionOrNull() ?: // 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") + // Log.v(TAG, "[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") - Log.e(TAG, "[EventSub] raw JSON: $jsonObject") - emitSystemMessage(message = "[EventSub] failed to parse message: $it") - continue - } + val message = + runCatching { json.decodeFromJsonElement(jsonObject) } + .getOrElse { + Log.e(TAG, "[EventSub] failed to parse message: $it") + Log.e(TAG, "[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}") @@ -146,10 +147,21 @@ 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 + } } } } @@ -159,7 +171,6 @@ class EventSubClient( shouldDiscardSession(sessionId) return@launch - } catch (t: Throwable) { Log.e(TAG, "[EventSub]($sessionId) connection failed: $t") emitSystemMessage(message = "[EventSub]($sessionId) connection failed: $t") @@ -198,18 +209,21 @@ class EventSubClient( 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? + Log.e(TAG, "[EventSub] failed to subscribe: $it") + emitSystemMessage(message = "[EventSub] failed to subscribe: $it") + return@withLock + } val subscription = response.data.firstOrNull()?.id if (subscription == null) { @@ -224,7 +238,8 @@ class EventSubClient( 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") @@ -260,42 +275,53 @@ class EventSubClient( private fun handleNotification(message: NotificationMessageDto) { Log.d(TAG, "[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, - ) - } + 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) } @@ -308,8 +334,15 @@ class EventSubClient( private fun DefaultClientWebSocketSession.handleReconnect(message: ReconnectMessageDto) { Log.i(TAG, "[EventSub] received request to reconnect: ${message.payload.session.reconnectUrl}") emitSystemMessage(message = "[EventSub] received request to reconnect") - when (val url = message.payload.session.reconnectUrl?.replaceFirst("ws://", "wss://")) { - null -> reconnect() + when ( + val url = + message.payload.session.reconnectUrl + ?.replaceFirst("ws://", "wss://") + ) { + null -> { + reconnect() + } + else -> { previousSession = this connect(url = url, twitchReconnect = true) @@ -337,7 +370,7 @@ class EventSubClient( return true } - else -> { + else -> { subscriptions.update { emptySet() } EventSubClientState.Disconnected } 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..e4757f033 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,10 @@ 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 } 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 634def945..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 @@ -29,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 { @@ -77,9 +78,10 @@ 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 @@ -94,15 +96,16 @@ class EventSubManager( } scope.launch { - 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 + 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 fdf5269b0..7398825d8 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 @@ -12,37 +12,12 @@ sealed interface EventSubMessage data class SystemMessage(val message: String) : EventSubMessage -data class ModerationAction( - val id: String, - val timestamp: Instant, - 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 +data class ModerationAction(val id: String, val timestamp: Instant, 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 98ee3b710..2b5756b33 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 @@ -11,21 +11,20 @@ import com.flxrs.dankchat.data.api.eventapi.dto.EventSubTransportDto sealed interface EventSubTopic { fun createRequest(sessionId: String): EventSubSubscriptionRequestDto + fun shortFormatted(): String - data class ChannelModerate( - val channel: UserName, - val broadcasterId: UserId, - val moderatorId: UserId, - ) : EventSubTopic { + data class ChannelModerate(val channel: UserName, val broadcasterId: UserId, val moderatorId: UserId) : EventSubTopic { override fun createRequest(sessionId: String) = EventSubSubscriptionRequestDto( type = EventSubSubscriptionType.ChannelModerate, version = "2", - condition = EventSubModeratorConditionDto( + condition = + EventSubModeratorConditionDto( broadcasterUserId = broadcasterId, moderatorUserId = moderatorId, ), - transport = EventSubTransportDto( + transport = + EventSubTransportDto( sessionId = sessionId, method = EventSubMethod.Websocket, ), @@ -34,19 +33,17 @@ sealed interface EventSubTopic { override fun shortFormatted(): String = "ChannelModerate($channel)" } - data class AutomodMessageHold( - val channel: UserName, - val broadcasterId: UserId, - val moderatorId: UserId, - ) : EventSubTopic { + 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( + condition = + EventSubModeratorConditionDto( broadcasterUserId = broadcasterId, moderatorUserId = moderatorId, ), - transport = EventSubTransportDto( + transport = + EventSubTransportDto( sessionId = sessionId, method = EventSubMethod.Websocket, ), @@ -55,19 +52,17 @@ sealed interface EventSubTopic { override fun shortFormatted(): String = "AutomodMessageHold($channel)" } - data class AutomodMessageUpdate( - val channel: UserName, - val broadcasterId: UserId, - val moderatorId: UserId, - ) : EventSubTopic { + 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( + condition = + EventSubModeratorConditionDto( broadcasterUserId = broadcasterId, moderatorUserId = moderatorId, ), - transport = EventSubTransportDto( + transport = + EventSubTransportDto( sessionId = sessionId, method = EventSubMethod.Websocket, ), @@ -76,19 +71,17 @@ sealed interface EventSubTopic { override fun shortFormatted(): String = "AutomodMessageUpdate($channel)" } - data class UserMessageHold( - val channel: UserName, - val broadcasterId: UserId, - val userId: UserId, - ) : EventSubTopic { + 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( + condition = + EventSubBroadcasterUserConditionDto( broadcasterUserId = broadcasterId, userId = userId, ), - transport = EventSubTransportDto( + transport = + EventSubTransportDto( sessionId = sessionId, method = EventSubMethod.Websocket, ), @@ -97,19 +90,17 @@ sealed interface EventSubTopic { override fun shortFormatted(): String = "UserMessageHold($channel)" } - data class UserMessageUpdate( - val channel: UserName, - val broadcasterId: UserId, - val userId: UserId, - ) : EventSubTopic { + 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( + condition = + EventSubBroadcasterUserConditionDto( broadcasterUserId = broadcasterId, userId = userId, ), - transport = EventSubTransportDto( + transport = + EventSubTransportDto( sessionId = sessionId, method = EventSubMethod.Websocket, ), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionRequestDto.kt index ca0b847e8..4828fde29 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionRequestDto.kt @@ -3,9 +3,4 @@ package com.flxrs.dankchat.data.api.eventapi.dto import kotlinx.serialization.Serializable @Serializable -data class EventSubSubscriptionRequestDto( - val type: EventSubSubscriptionType, - val version: String, - val condition: EventSubConditionDto, - val transport: EventSubTransportDto, -) +data class EventSubSubscriptionRequestDto(val type: EventSubSubscriptionType, val version: String, val condition: EventSubConditionDto, val transport: EventSubTransportDto) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt index 413cfab08..64a7e2c67 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("session_keepalive") -data class KeepAliveMessageDto( - override val metadata: EventSubMessageMetadataDto, - override val payload: EmptyPayload, -) : EventSubMessageDto +data class KeepAliveMessageDto(override val metadata: EventSubMessageMetadataDto, override val payload: EmptyPayload) : EventSubMessageDto 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 f21a16d21..29179c549 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 @@ -9,16 +9,10 @@ import kotlin.time.Instant @Serializable @SerialName("notification") -data class NotificationMessageDto( - override val metadata: EventSubSubscriptionMetadataDto, - override val payload: NotificationMessagePayload -) : EventSubMessageDto +data class NotificationMessageDto(override val metadata: EventSubSubscriptionMetadataDto, override val payload: NotificationMessagePayload) : EventSubMessageDto @Serializable -data class NotificationMessagePayload( - val subscription: SubscriptionPayloadDto, - val event: NotificationEventDto, -) : EventSubPayloadDto +data class NotificationMessagePayload(val subscription: SubscriptionPayloadDto, val event: NotificationEventDto) : EventSubPayloadDto @Serializable data class SubscriptionPayloadDto( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt index 8d618fe56..713e67adb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt @@ -5,12 +5,7 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("session_reconnect") -data class ReconnectMessageDto( - override val metadata: EventSubMessageMetadataDto, - override val payload: ReconnectMessagePayload, -) : EventSubMessageDto +data class ReconnectMessageDto(override val metadata: EventSubMessageMetadataDto, override val payload: ReconnectMessagePayload) : EventSubMessageDto @Serializable -data class ReconnectMessagePayload( - val session: SessionPayloadDto, -) : EventSubPayloadDto +data class ReconnectMessagePayload(val session: SessionPayloadDto) : EventSubPayloadDto diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt index ec64e7965..886de88d1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt @@ -5,12 +5,7 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("revocation") -data class RevocationMessageDto( - override val metadata: EventSubSubscriptionMetadataDto, - override val payload: RevocationMessagePayload, -) : EventSubMessageDto +data class RevocationMessageDto(override val metadata: EventSubSubscriptionMetadataDto, override val payload: RevocationMessagePayload) : EventSubMessageDto @Serializable -data class RevocationMessagePayload( - val subscription: SubscriptionPayloadDto, -) : EventSubPayloadDto +data class RevocationMessagePayload(val subscription: SubscriptionPayloadDto) : EventSubPayloadDto 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 ac9d60d94..6b122b01e 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 @@ -6,15 +6,10 @@ import kotlin.time.Instant @Serializable @SerialName("session_welcome") -data class WelcomeMessageDto( - override val metadata: EventSubMessageMetadataDto, - override val payload: WelcomeMessagePayload, -) : EventSubMessageDto +data class WelcomeMessageDto(override val metadata: EventSubMessageMetadataDto, override val payload: WelcomeMessagePayload) : EventSubMessageDto @Serializable -data class WelcomeMessagePayload( - val session: SessionPayloadDto, -) : EventSubPayloadDto +data class WelcomeMessagePayload(val session: SessionPayloadDto) : EventSubPayloadDto @Serializable data class SessionPayloadDto( 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 index 02ca9343a..73f2b2d8a 100644 --- 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 @@ -126,10 +126,7 @@ data class ChannelChatUserMessageUpdateDto( ) : NotificationEventDto @Serializable -data class AutomodHeldMessageDto( - val text: String, - val fragments: List = emptyList(), -) +data class AutomodHeldMessageDto(val text: String, val fragments: List = emptyList()) @Serializable data class AutomodMessageFragmentDto( @@ -138,10 +135,7 @@ data class AutomodMessageFragmentDto( ) @Serializable -data class AutomodReasonDto( - val category: String, - val level: Int, -) +data class AutomodReasonDto(val category: String, val level: Int) @Serializable data class BlockedTermReasonDto( 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 8b929ab54..b41ac5d0d 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 @@ -5,7 +5,6 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get class FFZApi(private val ktorClient: HttpClient) { - suspend fun getChannelEmotes(channelId: UserId) = ktorClient.get("room/id/$channelId") suspend fun getGlobalEmotes() = ktorClient.get("set/global") 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..fa78032a3 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 @@ -11,15 +11,16 @@ import org.koin.core.annotation.Single @Single 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 91ee6a3e4..b402dfa5e 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 @@ -6,7 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZChannelDto( - @SerialName(value = "room") val room: FFZRoomDto, - @SerialName(value = "sets") val sets: Map -) +data class FFZChannelDto(@SerialName(value = "room") val room: FFZRoomDto, @SerialName(value = "sets") val sets: Map) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt index cd70fbb06..e319e49ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt @@ -5,10 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZEmoteDto( - val urls: Map, - val animated: Map?, - val name: String, - val id: Int, - val owner: FFZEmoteOwnerDto?, -) +data class FFZEmoteDto(val urls: Map, val animated: Map?, val name: String, val id: Int, val owner: FFZEmoteOwnerDto?) 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 1a229f981..acf45c12b 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 @@ -6,7 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZGlobalDto( - @SerialName(value = "default_sets") val defaultSets: List, - @SerialName(value = "sets") val sets: Map -) +data class FFZGlobalDto(@SerialName(value = "default_sets") val defaultSets: List, @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 00f50da47..537b3581c 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 @@ -6,7 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZRoomDto( - @SerialName(value = "mod_urls") val modBadgeUrls: Map?, - @SerialName(value = "vip_badge") val vipBadgeUrls: Map?, -) +data class FFZRoomDto(@SerialName(value = "mod_urls") val modBadgeUrls: Map?, @SerialName(value = "vip_badge") val vipBadgeUrls: Map?) 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 302897ced..d553bcae8 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 @@ -29,7 +29,6 @@ import io.ktor.http.ContentType import io.ktor.http.contentType 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 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 9006f2830..05412e3e5 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 @@ -45,10 +45,10 @@ import org.koin.core.annotation.Single @Single 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 @@ -57,7 +57,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 @@ -74,14 +75,16 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { .mapCatching { it.id } suspend fun getChannelFollowers(broadcastUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi.getChannelFollowers(broadcastUserId, targetUserId) + 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 @@ -95,30 +98,26 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } 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 - ): Result = runCatching { - helixApi.postAnnouncement(broadcastUserId, moderatorUserId, request) + suspend fun postAnnouncement(broadcastUserId: UserId, moderatorUserId: UserId, request: AnnouncementRequestDto): Result = runCatching { + helixApi + .postAnnouncement(broadcastUserId, moderatorUserId, request) .throwHelixApiErrorOnFailure() } - suspend fun postWhisper( - fromUserId: UserId, - toUserId: UserId, - request: WhisperRequestDto - ): Result = runCatching { - helixApi.postWhisper(fromUserId, toUserId, request) + suspend fun postWhisper(fromUserId: UserId, toUserId: UserId, request: WhisperRequestDto): Result = runCatching { + helixApi + .postWhisper(fromUserId, toUserId, request) .throwHelixApiErrorOnFailure() } @@ -129,12 +128,14 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun postModerator(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.postModerator(broadcastUserId, userId) + helixApi + .postModerator(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } suspend fun deleteModerator(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.deleteModerator(broadcastUserId, userId) + helixApi + .deleteModerator(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } @@ -145,37 +146,44 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun postVip(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.postVip(broadcastUserId, userId) + helixApi + .postVip(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } suspend fun deleteVip(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.deleteVip(broadcastUserId, userId) + helixApi + .deleteVip(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } suspend fun postBan(broadcastUserId: UserId, moderatorUserId: UserId, requestDto: BanRequestDto): Result = runCatching { - helixApi.postBan(broadcastUserId, moderatorUserId, requestDto) + helixApi + .postBan(broadcastUserId, moderatorUserId, requestDto) .throwHelixApiErrorOnFailure() } suspend fun deleteBan(broadcastUserId: UserId, moderatorUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi.deleteBan(broadcastUserId, moderatorUserId, targetUserId) + helixApi + .deleteBan(broadcastUserId, moderatorUserId, targetUserId) .throwHelixApiErrorOnFailure() } suspend fun deleteMessages(broadcastUserId: UserId, moderatorUserId: UserId, messageId: String? = null): Result = runCatching { - helixApi.deleteMessages(broadcastUserId, moderatorUserId, messageId) + helixApi + .deleteMessages(broadcastUserId, moderatorUserId, messageId) .throwHelixApiErrorOnFailure() } suspend fun putUserChatColor(targetUserId: UserId, color: String): Result = runCatching { - helixApi.putUserChatColor(targetUserId, color) + helixApi + .putUserChatColor(targetUserId, color) .throwHelixApiErrorOnFailure() } suspend fun postMarker(requestDto: MarkerRequestDto): Result = runCatching { - helixApi.postMarker(requestDto) + helixApi + .postMarker(requestDto) .throwHelixApiErrorOnFailure() .body>() .data @@ -183,7 +191,8 @@ 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 @@ -191,7 +200,8 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun postRaid(broadcastUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi.postRaid(broadcastUserId, targetUserId) + helixApi + .postRaid(broadcastUserId, targetUserId) .throwHelixApiErrorOnFailure() .body>() .data @@ -199,12 +209,14 @@ 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) + helixApi + .patchChatSettings(broadcastUserId, moderatorUserId, request) .throwHelixApiErrorOnFailure() .body>() .data @@ -212,38 +224,44 @@ 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 getCheermotes(broadcasterId: UserId): Result> = runCatching { - helixApi.getCheermotes(broadcasterId) + 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)) + helixApi + .postManageAutomodMessage(ManageAutomodMessageRequestDto(userId = userId, msgId = msgId, action = action)) .throwHelixApiErrorOnFailure() } suspend fun postShoutout(broadcastUserId: UserId, targetUserId: UserId, moderatorUserId: UserId): Result = runCatching { - helixApi.postShoutout(broadcastUserId, targetUserId, moderatorUserId) + helixApi + .postShoutout(broadcastUserId, targetUserId, moderatorUserId) .throwHelixApiErrorOnFailure() } suspend fun getShieldMode(broadcastUserId: UserId, moderatorUserId: UserId): Result = runCatching { - helixApi.getShieldMode(broadcastUserId, moderatorUserId) + helixApi + .getShieldMode(broadcastUserId, moderatorUserId) .throwHelixApiErrorOnFailure() .body>() .data @@ -251,7 +269,8 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun putShieldMode(broadcastUserId: UserId, moderatorUserId: UserId, request: ShieldModeRequestDto): Result = runCatching { - helixApi.putShieldMode(broadcastUserId, moderatorUserId, request) + helixApi + .putShieldMode(broadcastUserId, moderatorUserId, request) .throwHelixApiErrorOnFailure() .body>() .data @@ -259,13 +278,15 @@ 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() } @@ -274,14 +295,16 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun getChannelEmotes(broadcasterId: UserId): Result> = runCatching { - helixApi.getChannelEmotes(broadcasterId) + helixApi + .getChannelEmotes(broadcasterId) .throwHelixApiErrorOnFailure() .body>() .data } suspend fun postChatMessage(request: SendChatMessageRequestDto): Result = runCatching { - helixApi.postChatMessage(request) + helixApi + .postChatMessage(request) .throwHelixApiErrorOnFailure() .body>() .data @@ -289,16 +312,18 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } private inline fun pageAsFlow(amountToFetch: Int, crossinline request: suspend (cursor: String?) -> HttpResponse?): Flow> = flow { - val initialPage = request(null) - .throwHelixApiErrorOnFailure() - .body>() + val initialPage = + request(null) + .throwHelixApiErrorOnFailure() + .body>() emit(initialPage.data) var cursor = initialPage.pagination.cursor var count = initialPage.data.size while (cursor != null && count < amountToFetch) { - val result = request(cursor) - .throwHelixApiErrorOnFailure() - .body>() + val result = + request(cursor) + .throwHelixApiErrorOnFailure() + .body>() emit(result.data) count += result.data.size cursor = result.pagination.cursor @@ -306,17 +331,19 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } private suspend inline fun pageUntil(amountToFetch: Int, request: (cursor: String?) -> HttpResponse?): List { - val initialPage = request(null) - .throwHelixApiErrorOnFailure() - .body>() + 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>() + val result = + request(cursor) + .throwHelixApiErrorOnFailure() + .body>() entries.addAll(result.data) cursor = result.pagination.cursor @@ -335,73 +362,131 @@ 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 - "/helix/chat/messages" -> HelixError.MessageTooLarge - 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 - "/helix/chat/messages" -> HelixError.ChatMessageRateLimited - 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) } 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 c70f3db78..e08ac3ab4 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 @@ -4,45 +4,71 @@ import com.flxrs.dankchat.data.api.ApiException import io.ktor.http.HttpStatusCode import io.ktor.http.Url -data class HelixApiException( - val error: HelixError, - override val status: HttpStatusCode, - override val url: Url?, - override val message: String? = null, - override val cause: Throwable? = null -) : ApiException(status, url, message, cause) +data class HelixApiException(val error: HelixError, override val status: HttpStatusCode, override val url: Url?, override val message: String? = 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 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 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/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/CheermoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CheermoteDto.kt index 786f0fcef..5f7cf820c 100644 --- 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 @@ -6,32 +6,16 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class CheermoteSetDto( - val prefix: String, - val tiers: List, - val type: String, - val order: Int, -) +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, -) +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, -) +data class CheermoteTierImagesDto(val dark: CheermoteThemeImagesDto, val light: CheermoteThemeImagesDto) @Keep @Serializable -data class CheermoteThemeImagesDto( - val animated: Map, - val static: Map, -) +data class CheermoteThemeImagesDto(val animated: Map, val static: Map) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt index ed037c94b..816b4c450 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt @@ -6,8 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class CommercialDto( - val length: Int, - val message: String?, - @SerialName("retry_after") val retryAfter: Int, -) +data class CommercialDto(val length: Int, val message: String?, @SerialName("retry_after") val retryAfter: Int) 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..14a53c233 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 @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class HelixErrorDto( - val status: Int, - val message: String -) +data class HelixErrorDto(val status: Int, val message: String) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt index b019c686d..980a08a3c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt @@ -6,9 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class MarkerDto( - val id: String, - val description: String?, - @SerialName("created_at") val createdAt: String, - @SerialName("position_seconds") val positionSeconds: Int, -) +data class MarkerDto(val id: String, val description: String?, @SerialName("created_at") val createdAt: String, @SerialName("position_seconds") val positionSeconds: Int) 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..946c7c1ac 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 @@ -7,7 +7,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class MarkerRequestDto( - @SerialName("user_id") val userId: UserId, - val description: String? -) +data class MarkerRequestDto(@SerialName("user_id") val userId: UserId, val description: String?) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt index 29fbd28a4..7aaa829c4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt @@ -9,8 +9,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class ModVipDto( - @SerialName("user_id") val userId: UserId, - @SerialName("user_login") val userLogin: UserName, - @SerialName("user_name") val userName: DisplayName, -) +data class ModVipDto(@SerialName("user_id") val userId: UserId, @SerialName("user_login") val userLogin: UserName, @SerialName("user_name") val userName: DisplayName) 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 index 3a1aa5e89..52a0f2d98 100644 --- 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 @@ -13,14 +13,7 @@ data class SendChatMessageRequestDto( ) @Serializable -data class SendChatMessageResponseDto( - @SerialName("message_id") val messageId: String, - @SerialName("is_sent") val isSent: Boolean, - @SerialName("drop_reason") val dropReason: DropReasonDto? = null, -) +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, -) +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..528f57ecb 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 @@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class ShieldModeRequestDto( - @SerialName("is_active") val isActive: Boolean -) +data class ShieldModeRequestDto(@SerialName("is_active") val isActive: Boolean) 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..5803f9a01 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 @@ -7,6 +7,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class UserBlockDto( - @SerialName(value = "user_id") val id: UserId -) +data class UserBlockDto(@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/UserFollowsDataDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDataDto.kt index 01e42ab6a..fd0ee55fa 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 @@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class UserFollowsDataDto( - @SerialName(value = "followed_at") val followedAt: String -) +data class UserFollowsDataDto(@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..76b834c35 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 @@ -6,7 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class UserFollowsDto( - @SerialName(value = "total") val total: Int, - @SerialName(value = "data") val data: List -) +data class UserFollowsDto(@SerialName(value = "total") val total: Int, @SerialName(value = "data") val data: List) 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 a14095d0e..b292b5f85 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 @@ -6,7 +6,6 @@ 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") { parameter("limit", limit) } 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..75de80918 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 @@ -12,14 +12,11 @@ import kotlinx.coroutines.flow.first import org.koin.core.annotation.Single @Single -class RecentMessagesApiClient( - private val recentMessagesApi: RecentMessagesApi, - private val chatSettingsDataStore: ChatSettingsDataStore, -) { - +class RecentMessagesApiClient(private val recentMessagesApi: RecentMessagesApi, private val chatSettingsDataStore: ChatSettingsDataStore) { 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 +29,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 e100f8495..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 + Unknown, } 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..9bf21acff 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 @@ -5,7 +5,6 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get 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..fbc977828 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 @@ -12,21 +12,23 @@ import org.koin.core.annotation.Single @Single 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/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..ee02029d8 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 @@ -5,12 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteDto( - val id: String, - val name: String, - val flags: Long, - val data: SevenTVEmoteDataDto? -) { +data class SevenTVEmoteDto(val id: String, val name: String, val flags: Long, val data: SevenTVEmoteDataDto?) { val isZeroWidth get() = (ZERO_WIDTH_FLAG and flags) == ZERO_WIDTH_FLAG companion object { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt index 5ae8893ad..8fd3d5602 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteFileDto( - val name: String, - val format: String, -) +data class SevenTVEmoteFileDto(val name: String, val format: String) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt index 6b3cfc9e3..8f949e168 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteHostDto( - val url: String, - val files: List, -) +data class SevenTVEmoteHostDto(val url: String, val files: List) 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..22060c42c 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 @@ -5,8 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteSetDto( - val id: String, - val name: String, - val emotes: List? -) +data class SevenTVEmoteSetDto(val id: String, val name: String, val emotes: 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..a7bc4e261 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 @@ -6,8 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVUserDto( - val id: String, - val user: SevenTVUserDataDto, - @SerialName("emote_set") val emoteSet: SevenTVEmoteSetDto? -) +data class SevenTVUserDto(val id: String, val user: SevenTVUserDataDto, @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 d1d76cd1b..58e6d3c45 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 @@ -60,14 +60,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,21 +96,22 @@ 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 - } + 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") delay(timeout) @@ -230,7 +234,7 @@ class SevenTVEventApiClient( 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}..") + Log.e(TAG, "[7TV Event-Api] attempting to reconnect #$reconnectAttempts..") connected = false connecting = false heartBeatJob?.cancel() @@ -246,13 +250,14 @@ class SevenTVEventApiClient( } override fun onMessage(webSocket: WebSocket, text: String) { - val message = runCatching { json.decodeFromString(text) }.getOrElse { - Log.d(TAG, "Failed to parse incoming message: ", it) - return - } + val message = + runCatching { json.decodeFromString(text) }.getOrElse { + Log.d(TAG, "Failed to parse incoming message: ", it) + return + } when (message) { - is HelloMessage -> { + is HelloMessage -> { heartBeatInterval = message.d.heartBeatInterval.milliseconds heartBeatJob = setupHeartBeatInterval() @@ -262,70 +267,87 @@ 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) 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..497286ac3 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,15 +4,10 @@ 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, - val added: List, - val removed: List, - val updated: List, - ) : SevenTVEventMessage { + data class EmoteSetUpdated(val emoteSetId: String, val actorName: DisplayName, val added: List, 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) } 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..65a7ca7d9 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 @@ -27,18 +27,11 @@ data class Actor(@SerialName("display_name") val displayName: DisplayName) @Serializable @SerialName("emote_set.update") -data class EmoteSetDispatchData( - override val body: EmoteSetChangeMapData -) : DispatchData +data class EmoteSetDispatchData(override val body: EmoteSetChangeMapData) : DispatchData @Serializable -data class EmoteSetChangeMapData( - override val id: String, - override val actor: Actor, - val pushed: List?, - val pulled: List?, - val updated: List? -) : ChangeMapData +data class EmoteSetChangeMapData(override val id: String, override val actor: Actor, val pushed: List?, val pulled: List?, val updated: List?) : + ChangeMapData @Serializable @JsonClassDiscriminator("key") @@ -50,16 +43,10 @@ data class EmoteChangeField(val value: SevenTVEmoteDto?, @SerialName("old_value" @Serializable @SerialName("user.update") -data class UserDispatchData( - override val body: UserChangeMapData -) : DispatchData +data class UserDispatchData(override val body: UserChangeMapData) : DispatchData @Serializable -data class UserChangeMapData( - override val id: String, - override val actor: Actor, - val updated: List? -) : ChangeMapData +data class UserChangeMapData(override val id: String, override val actor: Actor, val updated: List?) : ChangeMapData @Serializable @SerialName("connections") 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..4b3c84496 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 @@ -8,7 +8,4 @@ import kotlinx.serialization.Serializable data class HelloMessage(override val d: HelloData) : DataMessage @Serializable -data class HelloData( - @SerialName("heartbeat_interval") val heartBeatInterval: Int, - @SerialName("session_id") val sessionId: String, -) : Data +data class HelloData(@SerialName("heartbeat_interval") val heartBeatInterval: Int, @SerialName("session_id") val sessionId: String) : 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..77d355702 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 @@ -6,10 +6,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SubscribeRequest( - override val op: Int = 35, - override val d: SubscriptionData, -) : DataRequest { +data class SubscribeRequest(override val op: Int = 35, override val d: SubscriptionData) : DataRequest { companion object { fun userUpdates(userId: String) = SubscribeRequest( d = SubscriptionData(type = UserUpdates.type, condition = SubscriptionCondition(objectId = userId)), 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..828ed75cf 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 @@ -6,11 +6,11 @@ import kotlinx.serialization.Serializable 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 694f9ede0..691b19d09 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 @@ -6,7 +6,6 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter class SupibotApi(private val ktorClient: HttpClient) { - suspend fun getChannels(platformName: String = "twitch") = ktorClient.get("bot/channel/list") { parameter("platformName", platformName) } 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..8324a7bf7 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 @@ -11,21 +11,23 @@ import org.koin.core.annotation.Single @Single 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/SupibotCommandsDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandsDto.kt index 7aa6b733c..e76e084d1 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 @@ -7,4 +7,3 @@ import kotlinx.serialization.Serializable @Keep @Serializable data class SupibotCommandsDto(@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..85cf1c8f8 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 @@ -26,35 +26,35 @@ import java.net.URLConnection import java.time.Instant @Single -class UploadClient( - @Named(type = UploadOkHttpClient::class) private val httpClient: OkHttpClient, - private val toolsSettingsDataStore: ToolsSettingsDataStore, -) { - +class UploadClient(@Named(type = UploadOkHttpClient::class) private val httpClient: OkHttpClient, private val toolsSettingsDataStore: ToolsSettingsDataStore) { suspend fun uploadMedia(file: File): Result = withContext(Dispatchers.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,7 +67,7 @@ class UploadClient( UploadDto( imageLink = body, deleteLink = null, - timestamp = Instant.now() + timestamp = Instant.now(), ) } } @@ -80,13 +80,12 @@ class UploadClient( UploadDto( imageLink = imageLink, deleteLink = deleteLink, - timestamp = Instant.now() + timestamp = Instant.now(), ) } - } - else -> { + else -> { Log.e(TAG, "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)) 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 4457544df..0b88f9644 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 @@ -2,8 +2,4 @@ package com.flxrs.dankchat.data.api.upload.dto import java.time.Instant -data class UploadDto( - val imageLink: String, - val deleteLink: String?, - val timestamp: Instant -) +data class UploadDto(val imageLink: String, val deleteLink: String?, 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 index 1d353a5ce..932849585 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt @@ -23,68 +23,66 @@ 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 { - return 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, - ) - } +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) + 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), - ) + 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() }, - ) + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) private val persistScope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) 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 index 71b91a403..5184a4e47 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -27,8 +27,11 @@ 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 } @@ -46,7 +49,6 @@ class AuthStateCoordinator( private val startupValidationHolder: StartupValidationHolder, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.io) private val _events = Channel(Channel.BUFFERED) val events = _events.receiveAsFlow() @@ -70,7 +72,7 @@ class AuthStateCoordinator( } } - else -> { + else -> { channelDataCoordinator.cancelGlobalLoading() emoteRepository.clearTwitchEmotes() userStateRepository.clear() @@ -88,45 +90,49 @@ class AuthStateCoordinator( } 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 + 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 -> { - Log.e(TAG, "Failed to validate token: ${throwable.message}") - AuthEvent.ValidationFailed + else -> { + Log.e(TAG, "Failed to validate token: ${throwable.message}") + AuthEvent.ValidationFailed + } } - } - } - ) + }, + ) startupValidationHolder.update( when (result) { is AuthEvent.LoggedIn, - is AuthEvent.ValidationFailed -> StartupValidation.Validated + is AuthEvent.ValidationFailed, + -> StartupValidation.Validated + + is AuthEvent.ScopesOutdated -> StartupValidation.ScopesOutdated(result.userName) - is AuthEvent.ScopesOutdated -> StartupValidation.ScopesOutdated(result.userName) - AuthEvent.TokenInvalid -> StartupValidation.TokenInvalid - } + AuthEvent.TokenInvalid -> StartupValidation.TokenInvalid + }, ) // Only send snackbar-worthy events through the channel when (result) { is AuthEvent.LoggedIn, - is AuthEvent.ValidationFailed -> _events.send(result) + is AuthEvent.ValidationFailed, + -> _events.send(result) - else -> Unit + else -> Unit } return result 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 index f047589e8..b9260d3ea 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt @@ -9,8 +9,11 @@ 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 } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatImportance.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatImportance.kt index 734e497c3..4fe52a14b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatImportance.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatImportance.kt @@ -3,5 +3,5 @@ package com.flxrs.dankchat.data.chat enum class ChatImportance { REGULAR, SYSTEM, - DELETED + DELETED, } 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 3aa32727e..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 @@ -51,22 +51,31 @@ import com.flxrs.dankchat.data.database.entity.UserIgnoreEntity @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 602f5d537..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,7 +4,6 @@ import androidx.room.TypeConverter import java.time.Instant object InstantConverter { - @TypeConverter fun fromTimestamp(value: Long): Instant = Instant.ofEpochMilli(value) 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/EmoteUsageDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/EmoteUsageDao.kt index 87ada0182..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> 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 30255f9c9..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 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 568740815..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 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 2f608f50d..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> 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 d6fee0efd..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 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 fdf0f2551..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 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..b823f7f7a 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 @@ -12,11 +12,9 @@ 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 { 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..63e51ef19 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 @@ -13,7 +13,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,19 +22,18 @@ 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) 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 index 08ee28d52..89ae96283 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt @@ -8,25 +8,24 @@ import kotlinx.coroutines.flow.flow import org.koin.core.annotation.Single @Single -class ApiDebugSection( - private val helixApiStats: HelixApiStats, -) : DebugSection { - +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) + val ticker = + flow { + while (true) { + emit(Unit) + delay(2_000) + } } - } return combine(ticker) { - val statusCounts = helixApiStats.statusCounts - .entries - .sortedBy { it.key } - .map { (code, count) -> DebugEntry("HTTP $code", "$count") } + val statusCounts = + helixApiStats.statusCounts + .entries + .sortedBy { it.key } + .map { (code, count) -> DebugEntry("HTTP $code", "$count") } DebugSectionSnapshot( title = baseTitle, 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 index 11a01f191..96554d8ef 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt @@ -9,17 +9,17 @@ import org.koin.core.annotation.Single @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) + val ticker = + flow { + while (true) { + emit(Unit) + delay(2_000) + } } - } return ticker.map { val runtime = Runtime.getRuntime() val heapUsed = runtime.totalMemory() - runtime.freeMemory() @@ -30,7 +30,8 @@ class AppDebugSection : DebugSection { DebugSectionSnapshot( title = baseTitle, - entries = listOf( + entries = + listOf( DebugEntry("Total app memory", formatBytes(totalAppMemory)), DebugEntry("JVM heap", "${formatBytes(heapUsed)} / ${formatBytes(heapMax)}"), DebugEntry("Native heap", "${formatBytes(nativeAllocated)} / ${formatBytes(nativeTotal)}"), 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 index a8e0fd1eb..e4d239dfb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt @@ -7,28 +7,25 @@ import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single -class AuthDebugSection( - private val authDataStore: AuthDataStore, -) : DebugSection { - +class AuthDebugSection(private val authDataStore: AuthDataStore) : DebugSection { override val order = 2 override val baseTitle = "Auth" - override fun entries(): Flow { - return authDataStore.settings.map { auth -> - val tokenPreview = auth.oAuthKey + 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), - ) - ) - } + 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 index 1384c0282..c1c956ada 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt @@ -7,17 +7,17 @@ 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( + 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 index 2590a66f2..71d4902df 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt @@ -10,31 +10,34 @@ 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 { - +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 { - return chatChannelProvider.activeChannel.flatMapLatest { channel -> - when (channel) { - null -> flowOf(DebugSectionSnapshot(title = baseTitle, entries = listOf(DebugEntry("Channel", "None")))) - else -> chatMessageRepository.getChat(channel).map { messages -> + 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" })) + 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 index 7d1f788d9..49e27b9b2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt @@ -22,42 +22,46 @@ class ConnectionDebugSection( 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) + 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 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 pubSubStatus = + when { + pubSubManager.connected -> "Connected" + else -> "Disconnected" + } val sevenTvStatus = sevenTVEventApiClient.status() - val sevenTvText = when { - sevenTvStatus.connected -> "Connected (${sevenTvStatus.subscriptionCount} subs)" - else -> "Disconnected" - } + 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( + entries = + listOf( DebugEntry("IRC (read)", ircReadStatus), DebugEntry("IRC (write)", ircWriteStatus), DebugEntry("PubSub", pubSubStatus), 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 index b512e671a..37f687617 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow interface DebugSection { val baseTitle: String val order: Int + fun entries(): Flow } 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 index 58526c162..66e2122e6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt @@ -7,7 +7,6 @@ import org.koin.core.annotation.Single @Single class DebugSectionRegistry(sections: List) { - private val sorted = sections.sortedBy { it.order } fun allSections(): Flow> { 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 index b985664f2..f6a27dd40 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt @@ -11,51 +11,47 @@ 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 { - +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 { - return 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( + 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}"), - ), - ) - } + 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 index 63f5ed36a..e4c857880 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt @@ -7,18 +7,14 @@ import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Single @Single -class ErrorsDebugSection( - private val dataRepository: DataRepository, - private val chatMessageRepository: ChatMessageRepository, -) : DebugSection { - +class ErrorsDebugSection(private val dataRepository: DataRepository, private val chatMessageRepository: ChatMessageRepository) : DebugSection { override val order = 9 override val baseTitle = "Errors" - override fun entries(): Flow { - return combine(dataRepository.dataLoadingFailures, chatMessageRepository.chatLoadingFailures) { dataFailures, chatFailures -> - val totalFailures = dataFailures.size + chatFailures.size - val entries = buildList { + 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")) @@ -27,7 +23,6 @@ class ErrorsDebugSection( add(DebugEntry(failure.step::class.simpleName ?: "Unknown", failure.failure.message ?: "Unknown error")) } } - DebugSectionSnapshot(title = baseTitle, entries = entries) - } + 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 index 6d318b6c0..310f07b57 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt @@ -7,32 +7,27 @@ import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Single @Single -class RulesDebugSection( - private val highlightsRepository: HighlightsRepository, - private val ignoresRepository: IgnoresRepository, -) : DebugSection { - +class RulesDebugSection(private val highlightsRepository: HighlightsRepository, private val ignoresRepository: IgnoresRepository) : DebugSection { override val order = 8 override val baseTitle = "Rules" - override fun entries(): Flow { - return 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}"), - ), - ) - } + 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 index f71b68097..fd309774d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt @@ -16,33 +16,35 @@ class SessionDebugSection( 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) + 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") - } + 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( + entries = + listOf( DebugEntry("Uptime", uptime), DebugEntry("Send protocol", developerSettingsDataStore.current().chatSendProtocol.name), DebugEntry("Total messages received", "${chatMessageRepository.sessionMessageCount}"), @@ -50,7 +52,7 @@ class SessionDebugSection( 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 index 84178f734..7e23235e8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt @@ -8,27 +8,26 @@ import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Single @Single -class StreamDebugSection( - private val streamDataRepository: StreamDataRepository, - private val chatChannelProvider: ChatChannelProvider, -) : DebugSection { - +class StreamDebugSection(private val streamDataRepository: StreamDataRepository, private val chatChannelProvider: ChatChannelProvider) : DebugSection { override val order = 5 override val baseTitle = "Stream" - override fun entries(): Flow { - return 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( + 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( + else -> { + DebugSectionSnapshot( title = "$baseTitle$channelSuffix", - entries = listOf( + entries = + listOf( DebugEntry("Status", "Live"), DebugEntry("Viewers", "${stream.viewerCount}"), DebugEntry("Uptime", DateTimeUtils.calculateUptime(stream.startedAt)), 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 index 53ccd9e6a..d5c6842bd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt @@ -6,22 +6,18 @@ import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single -class UserStateDebugSection( - private val userStateRepository: UserStateRepository, -) : DebugSection { - +class UserStateDebugSection(private val userStateRepository: UserStateRepository) : DebugSection { override val order = 7 override val baseTitle = "User State" - override fun entries(): Flow { - return 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" }), - ), - ) - } + 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 6310ddc5d..07390d99a 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 @@ -2,20 +2,10 @@ package com.flxrs.dankchat.data.irc import java.text.ParseException -data class IrcMessage( - val raw: String, - val prefix: String, - val command: String, - val params: List = listOf(), - val tags: Map = mapOf() -) { - - fun isLoginFailed(): Boolean { - return command == "NOTICE" && params.getOrNull(0) == "*" && params.getOrNull(1) == "Login authentication failed" - } +data class IrcMessage(val raw: String, val prefix: String, val command: String, val params: List = listOf(), val tags: Map = mapOf()) { + 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) @@ -25,11 +15,26 @@ data class IrcMessage( 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('\\') + ':' -> { + append(';') + } + + 's' -> { + append(' ') + } + + 'r' -> { + append('\r') + } + + 'n' -> { + append('\n') + } + + '\\' -> { + append('\\') + } + else -> { append(value[i]) append(value[i + 1]) @@ -56,7 +61,7 @@ data class IrcMessage( while (message[pos] == ' ') pos++ } - //tags + // tags if (message[pos] == '@') { nextSpace = message.indexOf(' ') @@ -87,7 +92,7 @@ data class IrcMessage( skipTrailingWhitespace() - //prefix + // prefix if (message[pos] == ':') { nextSpace = message.indexOf(' ', pos) 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 81fec5510..3018f594d 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 @@ -6,13 +6,7 @@ import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.data.twitch.message.WhisperMessage import com.flxrs.dankchat.data.twitch.message.shouldNotify -data class NotificationData( - val channel: UserName, - val name: UserName, - val message: String, - val isWhisper: Boolean = false, - val isNotify: Boolean = false, -) +data class NotificationData(val channel: UserName, val name: UserName, val message: String, val isWhisper: Boolean = false, val isNotify: Boolean = false) fun Message.toNotificationData(): NotificationData? { if (!highlights.shouldNotify()) { @@ -20,14 +14,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 4452b3c2c..e9eb7c05c 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 @@ -42,8 +42,9 @@ import java.util.Locale import kotlin.concurrent.atomics.AtomicInt import kotlin.coroutines.CoroutineContext -class NotificationService : Service(), CoroutineScope { - +class NotificationService : + Service(), + CoroutineScope { private val binder = LocalBinder() private val manager: NotificationManager by lazy { getSystemService(NOTIFICATION_SERVICE) as NotificationManager } @@ -90,11 +91,12 @@ class NotificationService : Service(), CoroutineScope { super.onCreate() // minSdk 30 guarantees notification channel support (API 26+) 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 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) @@ -151,19 +153,21 @@ class NotificationService : Service(), CoroutineScope { 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() + 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 - } + val voice = + when { + forceEnglish -> tts?.voices?.find { it.locale == Locale.US && !it.isNetworkConnectionRequired } + else -> tts?.defaultVoice + } voice?.takeUnless { tts?.setVoice(it) == TextToSpeech.ERROR } ?: shutdownAndDisableTTS() } @@ -186,25 +190,29 @@ class NotificationService : Service(), CoroutineScope { 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 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 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) - .setStyle(MediaStyle().setShowActionsInCompactView(0)) // minSdk 30 guarantees MediaStyle support - .setContentIntent(pendingStartActivityIntent) - .setSmallIcon(R.drawable.ic_notification_icon) - .build() + 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)) // minSdk 30 guarantees MediaStyle support + .setContentIntent(pendingStartActivityIntent) + .setSmallIcon(R.drawable.ic_notification_icon) + .build() startForeground(NOTIFICATION_ID, notification) } @@ -214,95 +222,111 @@ class NotificationService : Service(), CoroutineScope { notifiedMessageIds.clear() notificationsJob?.cancel() - notificationsJob = launch { - chatNotificationRepository.notificationsFlow.collect { items -> - items.forEach { (message) -> - if (shouldNotifyOnMention && notificationsEnabled) { - if (!notifiedMessageIds.add(message.id)) { - return@forEach // Already notified for this message + notificationsJob = + launch { + chatNotificationRepository.notificationsFlow.collect { items -> + items.forEach { (message) -> + if (shouldNotifyOnMention && notificationsEnabled) { + if (!notifiedMessageIds.add(message.id)) { + return@forEach // Already notified for this message + } + if (notifiedMessageIds.size > MAX_NOTIFIED_IDS) { + val iterator = notifiedMessageIds.iterator() + iterator.next() + iterator.remove() + } + val data = message.toNotificationData() + data?.createMentionNotification() } - if (notifiedMessageIds.size > MAX_NOTIFIED_IDS) { - val iterator = notifiedMessageIds.iterator() - iterator.next() - iterator.remove() + + if (!message.shouldPlayTTS()) { + return@forEach } - 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 + } - 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 (!toolSettings.ttsEnabled || channel != activeTTSChannel || (audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { - return@forEach - } + if (tts == null) { + initTTS() + } - if (tts == null) { - initTTS() - } + if (message is PrivMessage && toolSettings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { + return@forEach + } - if (message is PrivMessage && toolSettings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { - return@forEach + message.playTTSMessage() } - - 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 + val message = + when (this) { + is UserNoticeMessage -> { + message } - when { - toolSettings.ttsMessageFormat == TTSMessageFormat.Message || name == previousTTSUser -> filtered - tts?.voice?.locale?.language == Locale.ENGLISH.language -> "$name said $filtered" - else -> "$name. $filtered" - }.also { previousTTSUser = name } + 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 queueMode = when (toolSettings.ttsPlayMode) { - TTSPlayMode.Queue -> TextToSpeech.QUEUE_ADD - TTSPlayMode.Newest -> TextToSpeech.QUEUE_FLUSH - } + 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) + toolSettings.ttsIgnoreEmotes -> { + emotes.fold(this) { acc, emote -> + acc.replace(emote.code, newValue = "", ignoreCase = true) + } } - else -> this + 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 } @@ -312,33 +336,39 @@ class NotificationService : Service(), CoroutineScope { } 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.fetchAndAdd(1), it, pendingIntentFlag) - } + 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 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 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 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 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 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 17d61c628..bf1361bd8 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 @@ -48,43 +48,49 @@ class HighlightsRepository( 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 { @@ -113,12 +119,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 +143,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 +166,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 +190,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,21 +215,22 @@ class HighlightsRepository( private fun UserNoticeMessage.calculateHighlightState(): UserNoticeMessage { val messageHighlights = validMessageHighlights.value - val highlights = buildSet { - val subsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Subscription) - 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.ofType(MessageHighlightEntityType.Announcement) + if (isAnnouncement && announcementsHighlight != null) { + add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) + } } - } return copy( highlights = highlights, - childMessage = childMessage?.calculateHighlightState() + childMessage = childMessage?.calculateHighlightState(), ) } @@ -244,93 +255,94 @@ class HighlightsRepository( val userHighlights = validUserHighlights.value val badgeHighlights = validBadgeHighlights.value val messageHighlights = validMessageHighlights.value - val highlights = buildSet { - val subsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Subscription) - 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.ofType(MessageHighlightEntityType.Announcement) + if (isAnnouncement && announcementsHighlight != null) { + add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) + } - val rewardsHighlight = messageHighlights.ofType(MessageHighlightEntityType.ChannelPointRedemption) - if (isReward && rewardsHighlight != null) { - add(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor)) - } + val rewardsHighlight = messageHighlights.ofType(MessageHighlightEntityType.ChannelPointRedemption) + if (isReward && rewardsHighlight != null) { + add(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor)) + } - val firstMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.FirstMessage) - if (isFirstMessage && firstMessageHighlight != null) { - add(Highlight(HighlightType.FirstMessage, firstMessageHighlight.customColor)) - } + val firstMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.FirstMessage) + if (isFirstMessage && firstMessageHighlight != null) { + add(Highlight(HighlightType.FirstMessage, firstMessageHighlight.customColor)) + } - val elevatedMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.ElevatedMessage) - if (isElevatedMessage && elevatedMessageHighlight != null) { - add(Highlight(HighlightType.ElevatedMessage, elevatedMessageHighlight.customColor)) - } + val elevatedMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.ElevatedMessage) + if (isElevatedMessage && elevatedMessageHighlight != null) { + add(Highlight(HighlightType.ElevatedMessage, elevatedMessageHighlight.customColor)) + } - if (containsCurrentUserName) { - val highlight = messageHighlights.ofType(MessageHighlightEntityType.Username) - if (highlight?.enabled == true) { - add(Highlight(HighlightType.Username, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight.createNotification) + if (containsCurrentUserName) { + val highlight = messageHighlights.ofType(MessageHighlightEntityType.Username) + if (highlight?.enabled == true) { + add(Highlight(HighlightType.Username, highlight.customColor)) + addNotificationHighlightIfEnabled(highlight.createNotification) + } } - } - if (containsParticipatedReply) { - val highlight = messageHighlights.ofType(MessageHighlightEntityType.Reply) - if (highlight?.enabled == true) { - add(Highlight(HighlightType.Reply, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight.createNotification) + if (containsParticipatedReply) { + val highlight = messageHighlights.ofType(MessageHighlightEntityType.Reply) + if (highlight?.enabled == true) { + add(Highlight(HighlightType.Reply, highlight.customColor)) + addNotificationHighlightIfEnabled(highlight.createNotification) + } } - } - messageHighlights - .filter { it.type == MessageHighlightEntityType.Custom } - .forEach { - val regex = it.regex ?: return@forEach + messageHighlights + .filter { it.type == MessageHighlightEntityType.Custom } + .forEach { + val regex = it.regex ?: return@forEach - if (message.contains(regex)) { + if (message.contains(regex)) { + add(Highlight(HighlightType.Custom, it.customColor)) + addNotificationHighlightIfEnabled(it.createNotification) + } + } + + userHighlights.forEach { + if (name.matches(it.username)) { add(Highlight(HighlightType.Custom, it.customColor)) addNotificationHighlightIfEnabled(it.createNotification) } } - - 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) + 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 + else -> this } - private fun List.ofType(type: MessageHighlightEntityType): MessageHighlightEntity? = - find { it.type == type } + private fun List.ofType(type: MessageHighlightEntityType): MessageHighlightEntity? = find { it.type == type } private fun MutableCollection.addNotificationHighlightIfEnabled(createNotification: Boolean) { if (createNotification) { @@ -355,19 +367,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 @@ -377,36 +392,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.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), + ) } } 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 9fa20dc51..44d49d81a 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 @@ -45,7 +45,6 @@ class IgnoresRepository( private val preferences: DankChatPreferenceStore, dispatchersProvider: DispatchersProvider, ) { - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) data class TwitchBlock(val id: UserId, val name: UserName) @@ -56,21 +55,21 @@ class IgnoresRepository( 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 { @@ -94,9 +93,7 @@ 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) { if (!preferences.isLoggedIn) { @@ -104,25 +101,28 @@ class IgnoresRepository( } 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 { + Log.d(TAG, "Failed to load user blocks for $userId", it) + 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 { + 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, + ) + } _twitchBlocks.update { twitchBlocks } } @@ -131,10 +131,11 @@ class IgnoresRepository( val result = helixApiClient.blockUser(targetUserId) if (result.isSuccess) { _twitchBlocks.update { - it + TwitchBlock( - id = targetUserId, - name = targetUsername, - ) + it + + TwitchBlock( + id = targetUserId, + name = targetUsername, + ) } } } @@ -143,10 +144,11 @@ class IgnoresRepository( val result = helixApiClient.unblockUser(targetUserId) if (result.isSuccess) { _twitchBlocks.update { - it - TwitchBlock( - id = targetUserId, - name = targetUsername, - ) + it - + TwitchBlock( + id = targetUserId, + name = targetUsername, + ) } } } @@ -154,14 +156,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 +182,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) } @@ -212,7 +216,7 @@ class IgnoresRepository( } return copy( - childMessage = childMessage?.applyIgnores() + childMessage = childMessage?.applyIgnores(), ) } @@ -251,7 +255,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), ) } @@ -259,8 +263,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 @@ -281,24 +286,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 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 @@ -327,32 +331,31 @@ 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 = 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 = ""), + ) } } 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..322163207 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 @@ -7,19 +7,17 @@ import kotlinx.coroutines.flow.Flow import org.koin.core.annotation.Single @Single -class RecentUploadsRepository( - private val recentUploadsDao: RecentUploadsDao -) { - +class RecentUploadsRepository(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 de9ddaba1..f080ae666 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 @@ -21,7 +21,6 @@ import java.util.concurrent.ConcurrentHashMap @Single class RepliesRepository(private val authDataStore: AuthDataStore) { - private val threads = ConcurrentHashMap>() fun getThreadItemsFlow(rootMessageId: String): Flow> = threads[rootMessageId]?.map { thread -> @@ -58,38 +57,44 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { val strippedMessage = message.stripLeadingReplyMention() 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() - ) - } - - else -> { - // Message already exists in thread - if (existing.replies.any { it.id == strippedMessage.id }) { - return strippedMessage + 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 }) { + return strippedMessage + } + + existing.copy(replies = existing.replies + strippedMessage, participated = existing.updateParticipated(strippedMessage)) + } } - } when { !threads.containsKey(rootId) -> threads[rootId] = MutableStateFlow(thread) - else -> threads.getValue(rootId).update { thread } + else -> threads.getValue(rootId).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 } - } + val parentInThread = + parentMessageId?.let { id -> + if (id == thread.rootMessageId) { + thread.rootMessage + } else { + thread.replies.find { it.id == id } + } + } val parentName: UserName val parentBody: String @@ -119,7 +124,7 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { } } - else -> { + else -> { val flow = threads[message.id] ?: return message flow.update { thread -> thread.copy(rootMessage = message) } } @@ -136,9 +141,7 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { return message.isParticipating() } - private fun PrivMessage.isParticipating(): Boolean { - return name == authDataStore.userName || (PARENT_MESSAGE_LOGIN_TAG in tags && tags[PARENT_MESSAGE_LOGIN_TAG] == authDataStore.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[PARENT_MESSAGE_DISPLAY_TAG] ?: return this @@ -149,7 +152,7 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { message = stripped, originalMessage = stripped, replyMentionOffset = displayName.length + 2, - emoteData = emoteData.copy(message = stripped) + emoteData = emoteData.copy(message = stripped), ) } @@ -175,9 +178,7 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { ) } - private fun PrivMessage.clearHighlight(): PrivMessage { - return copy(highlights = highlights.filter { it.type != HighlightType.Reply }.toSet()) - } + private fun PrivMessage.clearHighlight(): PrivMessage = copy(highlights = highlights.filter { it.type != HighlightType.Reply }.toSet()) companion object { private const val PARENT_MESSAGE_ID_TAG = "reply-parent-msg-id" 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..eaed987de 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 @@ -18,33 +18,32 @@ import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single @Single -class UserDisplayRepository( - private val userDisplayDao: UserDisplayDao, - dispatchersProvider: DispatchersProvider, -) { - +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 +59,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 +91,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/Channel.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/Channel.kt index c6b4134d4..11f287434 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/Channel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/Channel.kt @@ -4,9 +4,4 @@ import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -data class Channel( - val id: UserId, - val name: UserName, - val displayName: DisplayName, - val avatarUrl: String?, -) +data class Channel(val id: UserId, val name: UserName, val displayName: DisplayName, val avatarUrl: String?) 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 092faff71..4925b7557 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 @@ -21,12 +21,7 @@ import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap @Single -class ChannelRepository( - private val usersRepository: UsersRepository, - private val helixApiClient: HelixApiClient, - private val authDataStore: AuthDataStore, -) { - +class ChannelRepository(private val usersRepository: UsersRepository, private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore) { private val channelCache = ConcurrentHashMap() private val roomStates = ConcurrentHashMap() private val roomStateFlows = ConcurrentHashMap>() @@ -37,13 +32,19 @@ class ChannelRepository( return channelCache[name] } - val channel = when { - authDataStore.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 @@ -62,9 +63,11 @@ class ChannelRepository( 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 +76,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,14 +87,19 @@ 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) } @@ -108,10 +112,12 @@ class ChannelRepository( return@withContext cached } - val channels = helixApiClient.getUsersByIds(remaining) - .getOrNull() - .orEmpty() - .map { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } + 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 @@ -125,10 +131,12 @@ class ChannelRepository( 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 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 index 314ca9cd9..63dd3ad59 100644 --- 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 @@ -11,7 +11,6 @@ import org.koin.core.annotation.Single @Single class ChatChannelProvider(preferenceStore: DankChatPreferenceStore) { - private val _activeChannel = MutableStateFlow(null) private val _channels = MutableStateFlow(preferenceStore.channels.takeIf { it.isNotEmpty() }?.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 index cd183a355..fc138f48d 100644 --- 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 @@ -25,7 +25,6 @@ class ChatConnector( private val eventSubManager: EventSubManager, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val connectionState = ConcurrentHashMap>() @@ -34,8 +33,7 @@ class ChatConnector( val pubSubEvents get() = pubSubManager.messages val eventSubEvents get() = eventSubManager.events - fun getConnectionState(channel: UserName): StateFlow = - connectionState.getOrPut(channel) { MutableStateFlow(ConnectionState.DISCONNECTED) } + fun getConnectionState(channel: UserName): StateFlow = connectionState.getOrPut(channel) { MutableStateFlow(ConnectionState.DISCONNECTED) } fun setAllConnectionStates(state: ConnectionState) { connectionState.forEach { (_, flow) -> 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 index 8fb612496..db749d78d 100644 --- 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 @@ -68,7 +68,6 @@ class ChatEventProcessor( private val chatSettingsDataStore: ChatSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val lastMessage = ConcurrentHashMap() private val knownRewards = ConcurrentHashMap() @@ -103,12 +102,12 @@ class ChatEventProcessor( private suspend fun collectReadConnectionEvents() { chatConnector.readEvents.collect { event -> when (event) { - is ChatEvent.Connected -> handleConnected(event.isAnonymous) - is ChatEvent.Closed -> handleDisconnect() + 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() + is ChatEvent.LoginFailed -> postSystemMessageAndReconnect(SystemMessageType.LoginExpired) + is ChatEvent.Message -> onMessage(event.message) + is ChatEvent.Error -> handleDisconnect() } } } @@ -125,7 +124,7 @@ class ChatEventProcessor( chatConnector.pubSubEvents.collect { pubSubMessage -> when (pubSubMessage) { is PubSubMessage.PointRedemption -> handlePubSubReward(pubSubMessage) - is PubSubMessage.Whisper -> handlePubSubWhisper(pubSubMessage) + is PubSubMessage.Whisper -> handlePubSubWhisper(pubSubMessage) is PubSubMessage.ModeratorAction -> handlePubSubModeration(pubSubMessage) } } @@ -134,12 +133,12 @@ class ChatEventProcessor( 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 ModerationAction -> handleEventSubModeration(eventMessage) + is AutomodHeld -> handleAutomodHeld(eventMessage) + is AutomodUpdate -> handleAutomodUpdate(eventMessage) + is UserMessageHeld -> handleUserMessageHeld(eventMessage) is UserMessageUpdated -> handleUserMessageUpdated(eventMessage) - is SystemMessage -> postEventSubDebugMessage(eventMessage.message) + is SystemMessage -> postEventSubDebugMessage(eventMessage.message) } } } @@ -158,18 +157,19 @@ class ChatEventProcessor( knownRewards.remove(id) } - else -> { + else -> { Log.d(TAG, "Received pubsub reward message with id $id") knownRewards[id] = pubSubMessage } } } } else { - val message = runCatching { - messageProcessor.processReward( - PointRedemptionMessage.parsePointReward(pubSubMessage.timestamp, pubSubMessage.data) - ) - }.getOrNull() ?: return + val message = + runCatching { + messageProcessor.processReward( + PointRedemptionMessage.parsePointReward(pubSubMessage.timestamp, pubSubMessage.data), + ) + }.getOrNull() ?: return chatMessageRepository.addMessages(pubSubMessage.channelName, listOf(ChatItem(message))) } @@ -180,9 +180,10 @@ class ChatEventProcessor( return } - val message = runCatching { - messageProcessor.processWhisper(WhisperMessage.fromPubSub(pubSubMessage.data)) as? WhisperMessage - }.getOrNull() ?: return + val message = + runCatching { + messageProcessor.processWhisper(WhisperMessage.fromPubSub(pubSubMessage.data)) as? WhisperMessage + }.getOrNull() ?: return val item = ChatItem(message, isMentionTab = true) chatNotificationRepository.addWhisper(item) @@ -200,21 +201,23 @@ class ChatEventProcessor( 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 } + 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 { - Log.d(TAG, "Failed to parse event sub moderation message: $it") - return - } + val message = + runCatching { + ModerationMessage.parseModerationAction(id, timestamp, channelName, data) + }.getOrElse { + Log.d(TAG, "Failed to parse event sub moderation message: $it") + return + } chatMessageRepository.applyModerationMessage(message) } @@ -224,113 +227,122 @@ class ChatEventProcessor( knownAutomodHeldIds.add(data.messageId) val reason = formatAutomodReason(data.reason, data.automod, data.blockedTerm, data.message.text) val userColor = usersRepository.getCachedUserColor(data.userLogin) ?: Message.DEFAULT_COLOR - 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, - ) + 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 - } + 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, - ) + 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 - }, - ) + 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) + "CLEARCHAT" -> handleClearChat(msg) + "CLEARMSG" -> handleClearMsg(msg) + "ROOMSTATE" -> channelRepository.handleRoomState(msg) + "USERSTATE" -> userStateRepository.handleUserState(msg) "GLOBALUSERSTATE" -> userStateRepository.handleGlobalUserState(msg) - "WHISPER" -> handleWhisper(msg) - else -> handleMessage(msg) + "WHISPER" -> handleWhisper(msg) + else -> handleMessage(msg) } } private suspend fun onWriterMessage(message: IrcMessage) { when (message.command) { - "USERSTATE" -> userStateRepository.handleUserState(message) + "USERSTATE" -> userStateRepository.handleUserState(message) "GLOBALUSERSTATE" -> userStateRepository.handleGlobalUserState(message) - "NOTICE" -> handleMessage(message) + "NOTICE" -> handleMessage(message) } } @@ -341,13 +353,16 @@ class ChatEventProcessor( } 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() + 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) @@ -357,17 +372,19 @@ class ChatEventProcessor( } private fun handleClearChat(msg: IrcMessage) { - val parsed = runCatching { - ModerationMessage.parseClearChat(msg) - }.getOrElse { return } + val parsed = + runCatching { + ModerationMessage.parseClearChat(msg) + }.getOrElse { return } chatMessageRepository.applyModerationMessage(parsed) } private fun handleClearMsg(msg: IrcMessage) { - val parsed = runCatching { - ModerationMessage.parseClearMessage(msg) - }.getOrElse { return } + val parsed = + runCatching { + ModerationMessage.parseClearMessage(msg) + }.getOrElse { return } chatMessageRepository.applyModerationMessage(parsed) } @@ -384,11 +401,12 @@ class ChatEventProcessor( 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 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) @@ -418,14 +436,15 @@ class ChatEventProcessor( val additionalMessages = resolveRewardMessages(ircMessage) - val message = runCatching { - messageProcessor.processIrcMessage(ircMessage) { channel, id -> - chatMessageRepository.findMessage(id, channel, chatNotificationRepository.whispers) - } - }.getOrElse { - Log.e(TAG, "Failed to parse message", it) - return - } ?: return + val message = + runCatching { + messageProcessor.processIrcMessage(ircMessage) { channel, id -> + chatMessageRepository.findMessage(id, channel, chatNotificationRepository.whispers) + } + }.getOrElse { + Log.e(TAG, "Failed to parse message", it) + return + } ?: return if (message is NoticeMessage && usersRepository.isGlobalChannel(message.channel)) { chatMessageRepository.broadcastToAllChannels(ChatItem(message, importance = ChatImportance.SYSTEM)) @@ -434,30 +453,34 @@ class ChatEventProcessor( 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 + 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)) } - add(ChatItem(message, importance = importance)) - } - val channel = when (message) { - is PrivMessage -> message.channel - is UserNoticeMessage -> message.channel - is NoticeMessage -> message.channel - else -> return - } + val channel = + when (message) { + is PrivMessage -> message.channel + is UserNoticeMessage -> message.channel + is NoticeMessage -> message.channel + else -> return + } chatMessageRepository.addMessages(channel, additionalMessages + items) chatNotificationRepository.emitNotification(items) - val mentions = items - .filter { it.message.highlights.hasMention() } - .toMentionTabItems() + val mentions = + items + .filter { it.message.highlights.hasMention() } + .toMentionTabItems() if (mentions.isNotEmpty()) { chatNotificationRepository.addMentions(mentions) @@ -481,26 +504,28 @@ class ChatEventProcessor( return emptyList() } - 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) { - chatConnector.pubSubEvents - .filterIsInstance() - .first { it.data.reward.id == rewardId } - }?.also { knownRewards[rewardId] = it } - } - } + 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) { + chatConnector.pubSubEvents + .filterIsInstance() + .first { it.data.reward.id == rewardId } + }?.also { knownRewards[rewardId] = it } + } + } - return reward?.let { - val processed = messageProcessor.processReward(PointRedemptionMessage.parsePointReward(it.timestamp, it.data)) - listOfNotNull(processed?.let(::ChatItem)) - }.orEmpty() + return reward + ?.let { + val processed = messageProcessor.processReward(PointRedemptionMessage.parsePointReward(it.timestamp, it.data)) + listOfNotNull(processed?.let(::ChatItem)) + }.orEmpty() } private fun trackUserState(message: Message) { @@ -522,7 +547,7 @@ class ChatEventProcessor( val hasVip = message.badges.any { badge -> badge.badgeTag?.startsWith("vip") == true } when { hasVip -> userStateRepository.addVipChannel(message.channel) - else -> userStateRepository.removeVipChannel(message.channel) + else -> userStateRepository.removeVipChannel(message.channel) } } @@ -538,7 +563,13 @@ class ChatEventProcessor( } } - private fun postSystemMessageAndReconnect(type: SystemMessageType, channels: Set = chatChannelProvider.channels.value.orEmpty().toSet()) { + private fun postSystemMessageAndReconnect( + type: SystemMessageType, + channels: Set = + chatChannelProvider.channels.value + .orEmpty() + .toSet(), + ) { val reconnectedChannels = chatMessageRepository.addSystemMessageToChannels(type, channels) reconnectedChannels.forEach { channel -> scope.launch { @@ -550,29 +581,32 @@ class ChatEventProcessor( } private fun ConnectionState.toSystemMessageType(): SystemMessageType = when (this) { - ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected + ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected + ConnectionState.CONNECTED, - ConnectionState.CONNECTED_NOT_LOGGED_IN -> SystemMessageType.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)) + 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 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) + else -> { + TextResource.Plain(reason) + } } companion object { 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 index e6170ffc8..56f81bccf 100644 --- 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 @@ -38,7 +38,6 @@ class ChatMessageRepository( chatSettingsDataStore: ChatSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val messages = ConcurrentHashMap>>() private val _chatLoadingFailures = MutableStateFlow(emptySet()) @@ -63,15 +62,15 @@ class ChatMessageRepository( _sendFailureCount += 1 } - private val scrollBackLengthFlow = chatSettingsDataStore.debouncedScrollBack - .onEach { length -> - messages.forEach { (_, flow) -> - if (flow.value.size > length) { - flow.update { it.takeLast(length) } + 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) + }.stateIn(scope, SharingStarted.Eagerly, 500) val scrollBackLength get() = scrollBackLengthFlow.value val chatLoadingFailures = _chatLoadingFailures.asStateFlow() @@ -95,7 +94,7 @@ class ChatMessageRepository( when (message.action) { ModerationMessage.Action.Delete, ModerationMessage.Action.SharedDelete, - -> current.replaceWithTimeout(message, scrollBackLength, messageProcessor::onMessageRemoved) + -> current.replaceWithTimeout(message, scrollBackLength, messageProcessor::onMessageRemoved) else -> current.replaceOrAddModerationMessage(message, scrollBackLength, messageProcessor::onMessageRemoved) } @@ -119,28 +118,32 @@ class ChatMessageRepository( current.map { item -> val msg = item.message when { - msg is AutomodMessage && msg.heldMessageId == heldMessageId -> + msg is AutomodMessage && msg.heldMessageId == heldMessageId -> { item.copy(tag = item.tag + 1, message = msg.copy(status = status)) + } - else -> item + else -> { + item + } } } } } suspend fun reparseAllEmotesAndBadges() = withContext(Dispatchers.Default) { - messages.values.map { flow -> - async { - flow.update { items -> - items.map { - it.copy( - tag = it.tag + 1, - message = messageProcessor.reparseEmotesAndBadges(it.message), - ) + messages.values + .map { flow -> + async { + flow.update { items -> + items.map { + it.copy( + tag = it.tag + 1, + message = messageProcessor.reparseEmotesAndBadges(it.message), + ) + } } } - } - }.awaitAll() + }.awaitAll() chatNotificationRepository.reparseAll() } @@ -155,9 +158,10 @@ class ChatMessageRepository( channels.forEach { channel -> val flow = messages[channel] ?: return@forEach val current = flow.value - flow.value = current.addSystemMessage(type, scrollBackLength, messageProcessor::onMessageRemoved) { - reconnectedChannels += channel - } + flow.value = + current.addSystemMessage(type, scrollBackLength, messageProcessor::onMessageRemoved) { + reconnectedChannels += channel + } } return reconnectedChannels } 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 index 9bbb61b26..f020dec28 100644 --- 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 @@ -24,7 +24,6 @@ class ChatMessageSender( 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 @@ -33,7 +32,7 @@ class ChatMessageSender( val protocol = developerSettingsDataStore.current().chatSendProtocol when { forceIrc || protocol == ChatSendProtocol.IRC -> sendViaIrc(channel, message, replyId) - else -> sendViaHelix(channel, message, replyId) + else -> sendViaHelix(channel, message, replyId) } } @@ -42,10 +41,11 @@ class ChatMessageSender( 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 - } + val messageWithSuffix = + when { + currentLastMessage == trimmedMessage -> applyAntiDuplicate(trimmedMessage) + else -> trimmedMessage + } chatEventProcessor.setLastMessage(channel, messageWithSuffix) chatConnector.sendRaw("${replyIdOrBlank}PRIVMSG #$channel :$messageWithSuffix") @@ -54,21 +54,24 @@ class ChatMessageSender( 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, - ) + 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 -> @@ -78,11 +81,12 @@ class ChatMessageSender( chatMessageRepository.incrementSentMessageCount(ChatSendProtocol.Helix) } - else -> { - val type = when (val reason = response.dropReason) { - null -> SystemMessageType.SendNotDelivered - else -> SystemMessageType.SendDropped(reason.message, reason.code) - } + else -> { + val type = + when (val reason = response.dropReason) { + null -> SystemMessageType.SendNotDelivered + else -> SystemMessageType.SendDropped(reason.message, reason.code) + } postError(channel, type) } } @@ -100,29 +104,34 @@ class ChatMessageSender( } 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 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" + 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) + 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) + else -> { + SystemMessageType.SendFailed(message) + } } companion object { 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 index d5626e4ec..445242081 100644 --- 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 @@ -34,7 +34,6 @@ class ChatNotificationRepository( private val chatChannelProvider: ChatChannelProvider, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val _mentions = MutableStateFlow>(persistentListOf()) @@ -43,8 +42,9 @@ class ChatNotificationRepository( private val _channelMentionCount = mutableSharedFlowOf(mutableMapOf()) private val _unreadMessagesMap = mutableSharedFlowOf(mutableMapOf()) - private val scrollBackLengthFlow = chatSettingsDataStore.debouncedScrollBack - .stateIn(scope, SharingStarted.Eagerly, 500) + private val scrollBackLengthFlow = + chatSettingsDataStore.debouncedScrollBack + .stateIn(scope, SharingStarted.Eagerly, 500) private val scrollBackLength get() = scrollBackLengthFlow.value val notificationsFlow: SharedFlow> = _notificationsFlow.asSharedFlow() @@ -57,20 +57,22 @@ class ChatNotificationRepository( suspend fun reparseAll() { _mentions.update { items -> - items.map { - it.copy( - tag = it.tag + 1, - message = messageProcessor.reparseEmotesAndBadges(it.message), - ) - }.toImmutableList() + 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() + items + .map { + it.copy( + tag = it.tag + 1, + message = messageProcessor.reparseEmotesAndBadges(it.message), + ) + }.toImmutableList() } } 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 71fe8ee6d..8c6c217e5 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 @@ -33,7 +33,6 @@ class ChatRepository( private val authDataStore: AuthDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, ) { - val activeChannel get() = chatChannelProvider.activeChannel val channels get() = chatChannelProvider.channels @@ -93,20 +92,21 @@ class ChatRepository( val userState = userStateRepository.userState.value 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) ?: Message.DEFAULT_COLOR, + recipientId = null, + recipientColor = Message.DEFAULT_COLOR, + recipientName = split[1].toUserName(), + recipientDisplayName = split[1].toDisplayName(), + message = message, + rawEmotes = "", + rawBadges = "", + emotes = emotes, + ) val fakeItem = ChatItem(fakeMessage, isMentionTab = true) chatNotificationRepository.addWhisper(fakeItem) } @@ -118,7 +118,10 @@ class ChatRepository( suspend fun loadRecentMessagesIfEnabled(channel: UserName) { when { - chatSettingsDataStore.settings.first().loadMessageHistory -> chatEventProcessor.loadRecentMessages(channel) + chatSettingsDataStore.settings.first().loadMessageHistory -> { + chatEventProcessor.loadRecentMessages(channel) + } + else -> { chatMessageRepository.getMessagesFlow(channel)?.update { current -> current + SystemMessageType.NoHistoryLoaded.toChatItem() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt index 357b79bb9..9ce6d4429 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt @@ -27,60 +27,51 @@ class MessageProcessor( private val repliesRepository: RepliesRepository, private val channelRepository: ChannelRepository, ) { - /** Full pipeline: parse IRC → ignore → thread → display → emotes → highlights → thread update. Returns null if ignored or parse fails. */ - suspend fun processIrcMessage( - ircMessage: IrcMessage, - findMessageById: (UserName, String) -> Message? = { _, _ -> null }, - ): Message? { - return Message.parse(ircMessage, channelRepository::tryGetUserNameById) - ?.let { process(it, findMessageById) } - } + suspend fun processIrcMessage(ircMessage: IrcMessage, findMessageById: (UserName, String) -> 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? { - return message - .applyIgnores() - ?.calculateMessageThread(findMessageById) - ?.calculateUserDisplays() - ?.parseEmotesAndBadges() - ?.calculateHighlightState() - ?.updateMessageInThread() - } + 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? { - return message - .applyIgnores() - ?.calculateHighlightState() - ?.calculateUserDisplays() - } + suspend fun processReward(message: Message): Message? = message + .applyIgnores() + ?.calculateHighlightState() + ?.calculateUserDisplays() /** Partial pipeline for whisper messages (no thread step). */ - suspend fun processWhisper(message: Message): Message? { - return message - .applyIgnores() - ?.calculateHighlightState() - ?.calculateUserDisplays() - ?.parseEmotesAndBadges() - } + 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 { - return message.parseEmotesAndBadges().updateMessageInThread() - } + 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 index b616ee291..fa3717e41 100644 --- 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 @@ -35,13 +35,9 @@ class RecentMessagesHandler( private val chatMessageRepository: ChatMessageRepository, private val usersRepository: UsersRepository, ) { - private val loadedChannels = mutableSetOf() - data class Result( - val mentionItems: List, - val userSuggestions: List>, - ) + data class Result(val mentionItems: List, val userSuggestions: List>) @Suppress("LoopWithTooManyJumpStatements") suspend fun load(channel: UserName, isReconnect: Boolean = false): Result = withContext(Dispatchers.IO) { @@ -50,12 +46,13 @@ class RecentMessagesHandler( } val limit = if (isReconnect) RECENT_MESSAGES_LIMIT_AFTER_RECONNECT else null - val result = recentMessagesApiClient.getRecentMessages(channel, limit).getOrElse { throwable -> - if (!isReconnect) { - handleFailure(throwable, channel) + val result = + recentMessagesApiClient.getRecentMessages(channel, limit).getOrElse { throwable -> + if (!isReconnect) { + handleFailure(throwable, channel) + } + return@withContext Result(emptyList(), emptyList()) } - return@withContext Result(emptyList(), emptyList()) - } loadedChannels += channel val recentMessages = result.messages.orEmpty() @@ -73,25 +70,28 @@ class RecentMessagesHandler( when (parsedIrc.command) { "CLEARCHAT" -> { - val parsed = runCatching { - ModerationMessage.parseClearChat(parsedIrc) - }.getOrNull() ?: continue + val parsed = + runCatching { + ModerationMessage.parseClearChat(parsedIrc) + }.getOrNull() ?: continue items.replaceOrAddHistoryModerationMessage(parsed) } - "CLEARMSG" -> { - val parsed = runCatching { - ModerationMessage.parseClearMessage(parsedIrc) - }.getOrNull() ?: continue + "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 + else -> { + val message = + runCatching { + messageProcessor.processIrcMessage(parsedIrc) { _, id -> messageIndex[id] } + }.getOrNull() ?: continue messageIndex[message.id] = message if (message is PrivMessage) { @@ -102,11 +102,12 @@ class RecentMessagesHandler( } } - val importance = when { - isDeleted -> ChatImportance.DELETED - isReconnect -> ChatImportance.SYSTEM - else -> ChatImportance.REGULAR - } + 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) } @@ -118,13 +119,16 @@ class RecentMessagesHandler( 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() - } + val withIncompleteWarning = + when { + !isReconnect && recentMessages.isNotEmpty() && result.errorCode == RecentMessagesDto.ERROR_CHANNEL_NOT_JOINED -> { + current + SystemMessageType.MessageHistoryIncomplete.toChatItem() + } - else -> current - } + else -> { + current + } + } withIncompleteWarning.addAndLimit(items, chatMessageRepository.scrollBackLength, messageProcessor::onMessageRemoved, checkForDuplications = true) } @@ -134,21 +138,30 @@ class RecentMessagesHandler( } 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 -> { + val type = + when (throwable) { + !is RecentMessagesApiException -> { chatMessageRepository.addLoadingFailure(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable)) - SystemMessageType.MessageHistoryUnavailable(status = throwable.status.value.toString()) + 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)) } 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..35d131316 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 @@ -19,23 +19,18 @@ import kotlin.time.Duration.Companion.seconds @Single 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() - - suspend fun tryGetUserStateOrFallback( - minChannelsSize: Int, - initialTimeout: Duration = IRC_TIMEOUT_DELAY, - fallbackTimeout: Duration = IRC_TIMEOUT_SHORT_DELAY - ): UserState? { - return withTimeoutOrNull(initialTimeout) { getLatestValidUserState(minChannelsSize) } + }.take(count = 1) + .single() + + suspend fun tryGetUserStateOrFallback(minChannelsSize: Int, initialTimeout: Duration = IRC_TIMEOUT_DELAY, 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 +46,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 +66,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 bd55c7cad..7ee277663 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 @@ -16,6 +16,7 @@ class UsersRepository { 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>) { 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 8c5ddd751..16c268746 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 @@ -4,7 +4,7 @@ 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 ce8a9441e..4f79468b7 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 @@ -48,7 +48,6 @@ class CommandRepository( private val authDataStore: AuthDataStore, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val customCommands = chatSettingsDataStore.commands.stateIn(scope, SharingStarted.Eagerly, emptyList()) private val supibotCommands = mutableMapOf>>() @@ -56,9 +55,10 @@ 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 { @@ -68,7 +68,7 @@ class CommandRepository( .collect { enabled -> when { enabled -> loadSupibotCommands() - else -> clearSupibotCommands() + else -> clearSupibotCommands() } } } @@ -76,7 +76,7 @@ class CommandRepository( fun getCommandTriggers(channel: UserName): Flow> = when (channel) { WhisperMessage.WHISPER_CHANNEL -> flowOf(TwitchCommandRepository.asCommandTriggers(TwitchCommand.Whisper.trigger)) - else -> commandTriggers + else -> commandTriggers } fun getSupibotCommands(channel: UserName): StateFlow> = supibotCommands.getOrPut(channel) { MutableStateFlow(emptyList()) } @@ -112,11 +112,14 @@ 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) } } @@ -131,16 +134,19 @@ class CommandRepository( val (trigger, args) = triggerAndArgsOrNull(message) ?: return CommandResult.NotFound return when (val twitchCommand = twitchCommandRepository.findTwitchCommand(trigger)) { TwitchCommand.Whisper -> { - val currentUserId = authDataStore.userIdString - ?.takeIf { authDataStore.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 = "You must be logged in to use the $trigger command", + ) twitchCommandRepository.sendWhisper(twitchCommand, currentUserId, trigger, args) } - else -> CommandResult.NotFound + else -> { + CommandResult.NotFound + } } } @@ -180,27 +186,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 = authDataStore.userName ?: return emptyList() - return supibotApiClient.getSupibotUserAliases(user) + return supibotApiClient + .getSupibotUserAliases(user) .getOrNull() ?.let { (data) -> data.map { alias -> "$$${alias.name}" } @@ -217,8 +222,10 @@ class CommandRepository( } 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("User $target couldn't be blocked, no user with that name found!") val result = helixApiClient.blockUser(targetId) return when { @@ -227,7 +234,9 @@ class CommandRepository( CommandResult.AcceptedWithResponse("You successfully blocked user $target") } - else -> CommandResult.AcceptedWithResponse("User $target couldn't be blocked, an unknown error occurred!") + else -> { + CommandResult.AcceptedWithResponse("User $target couldn't be blocked, an unknown error occurred!") + } } } @@ -237,13 +246,16 @@ class CommandRepository( } 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("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") + } return result.getOrElse { CommandResult.AcceptedWithResponse("User $target couldn't be unblocked, an unknown error occurred!") @@ -251,19 +263,22 @@ class CommandRepository( } 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("Channel is not live.") val uptime = calculateUptime(result.startedAt) return CommandResult.AcceptedWithResponse("Uptime: $uptime") } private fun helpCommand(roomState: RoomState, userState: UserState): CommandResult.AcceptedWithResponse { - val commands = twitchCommandRepository - .getAvailableCommandTriggers(roomState, userState) - .plus(defaultCommandTriggers) - .joinToString(separator = " ") + val commands = + twitchCommandRepository + .getAvailableCommandTriggers(roomState, userState) + .plus(defaultCommandTriggers) + .joinToString(separator = " ") val response = "Commands available to you in this room: $commands" return CommandResult.AcceptedWithResponse(response) 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..7a2b1e0b1 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 @@ -4,10 +4,16 @@ import com.flxrs.dankchat.data.twitch.command.TwitchCommand 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 object NotFound : CommandResult + data object IrcCommand : CommandResult + data object Blocked : CommandResult } 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 105d830ba..fbf77795f 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 @@ -58,7 +58,6 @@ class DataRepository( private val chatSettingsDataStore: ChatSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val _dataLoadingFailures = MutableStateFlow(emptySet()) private val _dataUpdateEvents = MutableSharedFlow() @@ -68,7 +67,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,10 +99,13 @@ class DataRepository( fun clearDataLoadingFailures() = _dataLoadingFailures.update { emptySet() } 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 getStreams(channels: List): List? = helixApiClient.getStreams(channels).getOrNull() suspend fun reconnect() { @@ -129,28 +131,29 @@ class DataRepository( suspend fun loadGlobalBadges(): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "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 } + 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(): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "DankChat badges") { - dankChatApiClient.getDankChatBadges() + dankChatApiClient + .getDankChatBadges() .getOrEmitFailure { DataLoadingStep.DankChatBadges } .onSuccess { emoteRepository.setDankChatBadges(it) } .map { } } } - suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result { - return emoteRepository.loadUserEmotes(userId, onFirstPageLoaded) - .getOrEmitFailure { DataLoadingStep.TwitchEmotes } - } + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result = emoteRepository + .loadUserEmotes(userId, onFirstPageLoaded) + .getOrEmitFailure { DataLoadingStep.TwitchEmotes } suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) { emoteRepository.loadUserStateEmotes(globalEmoteSetIds, followerEmoteSetIds) @@ -162,11 +165,12 @@ class DataRepository( suspend fun loadChannelBadges(channel: UserName, id: UserId): Result = withContext(Dispatchers.IO) { measureTimeAndLog(TAG, "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) } + 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 { } } } @@ -177,7 +181,8 @@ class DataRepository( } measureTimeAndLog(TAG, "FFZ emotes for #$channel") { - ffzApiClient.getFFZChannelEmotes(channelId) + ffzApiClient + .getFFZChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } .onSuccess { it?.let { emoteRepository.setFFZEmotes(channel, it) } } .map { } @@ -190,7 +195,8 @@ class DataRepository( } measureTimeAndLog(TAG, "BTTV emotes for #$channel") { - bttvApiClient.getBTTVChannelEmotes(channelId) + bttvApiClient + .getBTTVChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } .onSuccess { it?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } .map { } @@ -203,7 +209,8 @@ class DataRepository( } measureTimeAndLog(TAG, "7TV emotes for #$channel") { - sevenTVApiClient.getSevenTVChannelEmotes(channelId) + sevenTVApiClient + .getSevenTVChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelSevenTVEmotes(channel, channelId) } .onSuccess { result -> result ?: return@onSuccess @@ -212,8 +219,7 @@ class DataRepository( } sevenTVEventApiClient.subscribeUser(result.user.id) emoteRepository.setSevenTVEmotes(channel, result) - } - .map { } + }.map { } } } @@ -223,7 +229,8 @@ class DataRepository( } measureTimeAndLog(TAG, "cheermotes for #$channel") { - helixApiClient.getCheermotes(channelId) + helixApiClient + .getCheermotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } .onSuccess { emoteRepository.setCheermotes(channel, it) } .map { } @@ -236,7 +243,8 @@ class DataRepository( } measureTimeAndLog(TAG, "global FFZ emotes") { - ffzApiClient.getFFZGlobalEmotes() + ffzApiClient + .getFFZGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalFFZEmotes } .onSuccess { emoteRepository.setFFZGlobalEmotes(it) } .map { } @@ -249,7 +257,8 @@ class DataRepository( } measureTimeAndLog(TAG, "global BTTV emotes") { - bttvApiClient.getBTTVGlobalEmotes() + bttvApiClient + .getBTTVGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalBTTVEmotes } .onSuccess { emoteRepository.setBTTVGlobalEmotes(it) } .map { } @@ -262,7 +271,8 @@ class DataRepository( } measureTimeAndLog(TAG, "global 7TV emotes") { - sevenTVApiClient.getSevenTVGlobalEmotes() + sevenTVApiClient + .getSevenTVGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalSevenTVEmotes } .onSuccess { emoteRepository.setSevenTVGlobalEmotes(it) } .map { } 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..dadbcac2a 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 @@ -8,5 +8,6 @@ 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 } 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 index d63c4a62b..1a07c6f46 100644 --- 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 @@ -19,12 +19,7 @@ import org.koin.core.annotation.Single data class EmojiData(val code: String, val unicode: String) @Single -class EmojiRepository( - private val context: Context, - private val json: Json, - dispatchersProvider: DispatchersProvider, -) { - +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() 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 7fdd71dfd..69bb9fef0 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 @@ -63,7 +63,6 @@ import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList -@Suppress("LargeClass") @Single class EmoteRepository( private val dankChatApiClient: DankChatApiClient, @@ -129,15 +128,16 @@ class EmoteRepository( return buildList { 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 - ) + 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 } @@ -149,47 +149,63 @@ class EmoteRepository( val emoteData = message.emoteData ?: return message val (messageString, channel, emotesWithPositions) = emoteData - val withEmojiFix = messageString.replace( - ESCAPE_TAG_REGEX, - ZERO_WIDTH_JOINER - ) + val withEmojiFix = + messageString.replace( + ESCAPE_TAG_REGEX, + ZERO_WIDTH_JOINER, + ) // 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, - message = appendedSpaceAdjustedMessage, - supplementaryCodePointPositions = supplementaryCodePointPositions, - appendedSpaces = appendedSpaces, - removedSpaces = removedSpaces, - replyMentionOffset = replyMentionOffset - ) + val twitchEmotes = + parseTwitchEmotes( + emotesWithPositions = emotesWithPositions, + message = appendedSpaceAdjustedMessage, + supplementaryCodePointPositions = supplementaryCodePointPositions, + appendedSpaces = appendedSpaces, + removedSpaces = removedSpaces, + replyMentionOffset = replyMentionOffset, + ) val twitchEmoteCodes = twitchEmotes.mapTo(mutableSetOf()) { it.code } - val cheermotes = when { - message is PrivMessage && message.tags["bits"] != null -> parseCheermotes(appendedSpaceAdjustedMessage, channel) - else -> emptyList() - } + val cheermotes = + when { + message is PrivMessage && message.tags["bits"] != null -> parseCheermotes(appendedSpaceAdjustedMessage, channel) + else -> emptyList() + } val cheermoteCodes = cheermotes.mapTo(mutableSetOf()) { it.code } - val thirdPartyEmotes = parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel) - .filterNot { it.code in twitchEmoteCodes || it.code in cheermoteCodes } + val thirdPartyEmotes = + parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel) + .filterNot { it.code in twitchEmoteCodes || it.code in cheermoteCodes } 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) } @@ -198,73 +214,94 @@ 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 + } } } @@ -281,14 +318,24 @@ 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 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 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 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] } @@ -308,7 +355,7 @@ 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()}", ) } @@ -331,16 +378,11 @@ class EmoteRepository( fun getSevenTVUserDetails(channel: UserName): SevenTVUserDetails? = sevenTvChannelDetails[channel] - suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result { - return runCatching { - loadUserEmotesViaHelix(userId, onFirstPageLoaded) - } + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result = runCatching { + loadUserEmotesViaHelix(userId, onFirstPageLoaded) } - private suspend fun loadUserEmotesViaHelix( - userId: UserId, - onFirstPageLoaded: (() -> Unit)? = null - ) = withContext(Dispatchers.Default) { + private suspend fun loadUserEmotesViaHelix(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) = withContext(Dispatchers.Default) { val seenIds = mutableSetOf() val allEmotes = mutableListOf() var totalCount = 0 @@ -365,33 +407,39 @@ class EmoteRepository( // 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 ownerIds = + newChannelDtos + .filter { it.ownerId.isNotBlank() } + .map { it.ownerId.toUserId() } + .distinct() - val channelsByIdMap = channelRepository.getChannelsByIds(ownerIds) - .associateBy { it.id } + 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 + 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 + } } - - "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 - } newGlobalEmotes.add(emote.toGenericEmote(type)) } } @@ -411,30 +459,36 @@ class EmoteRepository( } 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() - } - .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 + val sets = + (globalEmoteSetIds + followerEmoteSetIds.values.flatten()) + .distinct() + .chunkedBy(maxSize = MAX_PARAMS_LENGTH) { it.length + 3 } + .concurrentMap { + dankChatApiClient + .getUserSets(it) + .getOrNull() + .orEmpty() + }.flatten() + + val twitchEmotes = + sets.flatMap { emoteSet -> + val type = + when (val set = emoteSet.id) { + "0", "42" -> { + EmoteType.GlobalTwitchEmote } - ?.let { EmoteType.ChannelTwitchFollowerEmote(it.key) } - ?: emoteSet.channelName.twitchEmoteType - } + + // 42 == monkey emote set, move them to the global emote section + else -> { + followerEmoteSetIds.entries + .find { (_, sets) -> + set in sets + }?.let { EmoteType.ChannelTwitchFollowerEmote(it.key) } + ?: emoteSet.channelName.twitchEmoteType + } + } + emoteSet.emotes.mapToGenericEmotes(type) } - emoteSet.emotes.mapToGenericEmotes(type) - } val globalTwitchEmotes = twitchEmotes.filter { it.emoteType is EmoteType.GlobalTwitchEmote || it.emoteType is EmoteType.ChannelTwitchEmote } val followerEmotes = twitchEmotes.filter { it.emoteType is EmoteType.ChannelTwitchFollowerEmote } @@ -444,19 +498,20 @@ class EmoteRepository( channelEmoteStates.forEach { (channel, flow) -> flow.update { it.copy( - twitchEmotes = followerEmotes.filterNot { emote -> emote.emoteType is EmoteType.ChannelTwitchFollowerEmote && emote.emoteType.channel != channel } + twitchEmotes = followerEmotes.filterNot { emote -> emote.emoteType is EmoteType.ChannelTwitchFollowerEmote && emote.emoteType.channel != channel }, ) } } } suspend fun setFFZEmotes(channel: UserName, ffzResult: FFZChannelDto) = withContext(Dispatchers.Default) { - val ffzEmotes = ffzResult.sets - .flatMap { set -> - set.value.emotes.mapNotNull { - parseFFZEmote(it, channel) + val ffzEmotes = + ffzResult.sets + .flatMap { set -> + set.value.emotes.mapNotNull { + parseFFZEmote(it, channel) + } } - } channelEmoteStates[channel]?.update { it.copy(ffzEmotes = ffzEmotes) } @@ -471,13 +526,14 @@ class EmoteRepository( } 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) + val ffzGlobalEmotes = + ffzResult.sets + .filter { it.key in ffzResult.defaultSets } + .flatMap { (_, emoteSet) -> + emoteSet.emotes.mapNotNull { emote -> + parseFFZEmote(emote, channel = null) + } } - } globalEmoteState.update { it.copy(ffzEmotes = ffzGlobalEmotes) } } @@ -497,16 +553,18 @@ class EmoteRepository( 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 })) + } channelEmoteStates[channel]?.update { it.copy(sevenTvEmotes = sevenTvEmotes) @@ -518,12 +576,13 @@ class EmoteRepository( 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 })) + } channelEmoteStates[channel]?.update { it.copy(sevenTvEmotes = sevenTvEmotes) @@ -531,29 +590,32 @@ class EmoteRepository( } 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 })) - } + val addedEmotes = + event.added + .filterUnlistedIfEnabled() + .mapNotNull { emote -> + parseSevenTVEmote(emote, EmoteType.ChannelSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) + } 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 + 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 + } } - } state.copy(sevenTvEmotes = updated + addedEmotes) } } @@ -561,36 +623,44 @@ class EmoteRepository( suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = withContext(Dispatchers.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) } } suspend fun setCheermotes(channel: UserName, cheermoteDtos: List) = withContext(Dispatchers.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(), - ) - } - ) - } + 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) } @@ -608,16 +678,17 @@ class EmoteRepository( if (match != null) { val bits = match.groupValues[1].toIntOrNull() ?: break val tier = set.tiers.firstOrNull { bits >= it.minBits } ?: break - this += 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, - ) + this += + 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, + ) break } } @@ -627,43 +698,47 @@ class EmoteRepository( } private val UserName?.twitchEmoteType: EmoteType - get() = when { - this == null || isGlobalTwitchChannel -> EmoteType.GlobalTwitchEmote - else -> EmoteType.ChannelTwitchEmote(this) - } + get() = + when { + this == null || isGlobalTwitchChannel -> EmoteType.GlobalTwitchEmote + else -> EmoteType.ChannelTwitchEmote(this) + } private val UserName.isGlobalTwitchChannel: Boolean get() = value.equals("qa_TW_Partner", ignoreCase = true) || value.equals("Twitch", ignoreCase = true) private fun UserEmoteDto.toGenericEmote(type: EmoteType): GenericEmote { - val code = when (type) { - is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name - else -> name - } + 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), id = id, scale = 1, - emoteType = type + emoteType = type, ) } - private fun List?.mapToGenericEmotes(type: EmoteType): List = this?.map { (name, id) -> - val code = when (type) { - is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name - else -> name - } - GenericEmote( - code = code, - url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), - lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), - id = id, - scale = 1, - emoteType = type - ) - }.orEmpty() + private fun List?.mapToGenericEmotes(type: EmoteType): List = this + ?.map { (name, id) -> + val code = + when (type) { + is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name + else -> name + } + GenericEmote( + code = code, + url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), + lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), + id = id, + scale = 1, + emoteType = type, + ) + }.orEmpty() @VisibleForTesting fun adjustOverlayEmotes(message: String, emotes: List): Pair> { @@ -694,10 +769,11 @@ class EmoteRepository( 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 @@ -761,7 +837,7 @@ class EmoteRepository( code = code, scale = 1, type = ChatMessageEmoteType.TwitchEmote, - isTwitch = true + isTwitch = true, ) } } @@ -803,17 +879,19 @@ class EmoteRepository( val id = emote.id 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"] - } + 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) } @@ -824,12 +902,13 @@ class EmoteRepository( } 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) - } + 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, @@ -842,20 +921,19 @@ class EmoteRepository( ) } - private fun SevenTVEmoteFileDto.emoteUrlWithFallback(base: String): String { - return "$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 TAG = EmoteRepository::class.java.simpleName @@ -876,30 +954,38 @@ class EmoteRepository( private const val BTTV_LOW_RES_EMOTE_SIZE = "2x" private val WHITESPACE_REGEX = "\\s".toRegex() - 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 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", + ) } } 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 c95e9373b..9eb2332bd 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 @@ -14,16 +14,14 @@ import org.koin.core.annotation.Single import java.time.Instant @Single -class EmoteUsageRepository( - private val emoteUsageDao: EmoteUsageDao, - dispatchersProvider: DispatchersProvider, -) { - +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()) + val recentEmoteIds: StateFlow> = + emoteUsageDao + .getRecentUsages() + .map { usages -> usages.mapTo(mutableSetOf()) { it.emoteId } } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) init { scope.launch { @@ -34,13 +32,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 f33518edd..d3b9d01ba 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 @@ -42,18 +42,18 @@ data class Emotes( val sevenTvChannelEmotes: List = emptyList(), val sevenTvGlobalEmotes: List = emptyList(), ) { - - val sorted: List = buildList { - addAll(twitchEmotes) - - addAll(ffzChannelEmotes) - addAll(bttvChannelEmotes) - addAll(sevenTvChannelEmotes) - - addAll(ffzGlobalEmotes) - addAll(bttvGlobalEmotes) - addAll(sevenTvGlobalEmotes) - }.sortedBy(GenericEmote::code) + val sorted: List = + buildList { + addAll(twitchEmotes) + + addAll(ffzChannelEmotes) + addAll(bttvChannelEmotes) + addAll(sevenTvChannelEmotes) + + 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/stream/StreamData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamData.kt index 4d71096dc..770c716c0 100644 --- 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 @@ -2,10 +2,4 @@ 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, -) +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 index b69aa0715..45a9f4a48 100644 --- 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 @@ -49,30 +49,35 @@ class StreamDataRepository( return@launch } - fetchTimerJob = timer(STREAM_REFRESH_RATE) { - fetchOnce(channels) - } + 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) + 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( + channel = it.userLogin, + formattedData = formatted, + viewerCount = it.viewerCount, + startedAt = it.startedAt, + category = it.category, + ) + }.orEmpty() _streamData.value = data.toImmutableList() } 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 index 4f4de27b1..893102627 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -7,55 +7,37 @@ 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 + + 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 + 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 + + 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..6df7447e6 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,12 @@ 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 - ) : DataLoadingState + + data class Failed(val errorMessage: String, val errorCount: Int, val dataFailures: 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..6379cc502 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,11 @@ 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 } 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..e04881774 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 @@ -12,10 +12,15 @@ sealed class Badge : Parcelable { 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 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..ae166a253 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 @@ -3,45 +3,40 @@ package com.flxrs.dankchat.data.twitch.badge import com.flxrs.dankchat.data.api.badges.dto.TwitchBadgeSetsDto import com.flxrs.dankchat.data.api.helix.dto.BadgeSetDto -data class BadgeSet( - val id: String, - val versions: Map -) +data class BadgeSet(val id: String, val versions: Map) -data class BadgeVersion( - val id: String, - val title: String, - val imageUrlLow: String, - val imageUrlMedium: String, - val imageUrlHigh: String -) +data class BadgeVersion(val id: String, val title: String, val imageUrlLow: String, val imageUrlMedium: String, val imageUrlHigh: String) fun TwitchBadgeSetsDto.toBadgeSets(): Map = sets.mapValues { (id, set) -> BadgeSet( id = id, - versions = set.versions.mapValues { (badgeId, badge) -> + versions = + set.versions.mapValues { (badgeId, badge) -> BadgeVersion( id = badgeId, title = badge.title, imageUrlLow = badge.imageUrlLow, imageUrlMedium = badge.imageUrlMedium, - imageUrlHigh = badge.imageUrlHigh + 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 e2d1e2665..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 dbb716d77..c43380538 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 @@ -40,15 +40,20 @@ import kotlin.time.times 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 object LoginFailed : ChatEvent + data object Closed : ChatEvent val isDisconnected: Boolean @@ -64,9 +69,10 @@ class ChatConnection( private val url: String = IRC_URL, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - private val client = httpClient.config { - install(WebSockets) - } + private val client = + httpClient.config { + install(WebSockets) + } @Volatile private var session: DefaultClientWebSocketSession? = null @@ -87,9 +93,10 @@ class ChatConnection( 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 { @@ -97,7 +104,8 @@ class ChatConnection( 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 -> currentSession.joinChannels(chunk) @@ -155,112 +163,128 @@ class ChatConnection( currentOAuth = authDataStore.oAuthKey awaitingPong = false - 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()) { - Log.e(TAG, "[$chatConnectionType] authentication failed with expired token, closing connection..") - receiveChannel.send(ChatEvent.LoginFailed) - return@webSocket - } - - when (ircMessage.command) { - "376" -> { - Log.i(TAG, "[$chatConnectionType] connected to irc") - pingJob = setupPingInterval() - channelsToJoin.send(channels) - } + 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 + } - "JOIN" -> { - val channel = ircMessage.params.getOrNull(0)?.substring(1)?.toUserName() ?: return@forEach - if (channelsAttemptedToJoin.remove(channel)) { - Log.i(TAG, "[$chatConnectionType] Joined #$channel") + else -> { + (frame as? Frame.Text)?.readText() ?: continue } } - "366" -> receiveChannel.send(ChatEvent.Connected(ircMessage.params[1].substring(1).toUserName(), isAnonymous)) - "PING" -> sendIrc("PONG :tmi.twitch.tv") - "PONG" -> awaitingPong = false - "RECONNECT" -> { - Log.i(TAG, "[$chatConnectionType] server requested reconnect") - serverRequestedReconnect = true + 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..") + receiveChannel.send(ChatEvent.LoginFailed) return@webSocket } - else -> { - if (ircMessage.command == "NOTICE" && ircMessage.tags["msg-id"] == "msg_channel_suspended") { - channelsAttemptedToJoin.remove(ircMessage.params[0].substring(1).toUserName()) + when (ircMessage.command) { + "376" -> { + Log.i(TAG, "[$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)) { + Log.i(TAG, "[$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" -> { + Log.i(TAG, "[$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)) } - receiveChannel.send(ChatEvent.Message(ircMessage)) } } } + } finally { + pingJob?.cancel() } - } finally { - pingJob?.cancel() } - } - _connected.value = false - session = null - channelsAttemptedToJoin.clear() - receiveChannel.send(ChatEvent.Closed) + _connected.value = false + session = null + channelsAttemptedToJoin.clear() + receiveChannel.send(ChatEvent.Closed) - if (!serverRequestedReconnect) { - Log.i(TAG, "[$chatConnectionType] connection closed") - return@launch + if (!serverRequestedReconnect) { + Log.i(TAG, "[$chatConnectionType] connection closed") + return@launch + } + Log.i(TAG, "[$chatConnectionType] reconnecting after server request") + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + Log.e(TAG, "[$chatConnectionType] connection failed: $t") + Log.e(TAG, "[$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) } - Log.i(TAG, "[$chatConnectionType] reconnecting after server request") - - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - Log.e(TAG, "[$chatConnectionType] connection failed: $t") - Log.e(TAG, "[$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) } - } - Log.e(TAG, "[$chatConnectionType] connection failed after $RECONNECT_MAX_ATTEMPTS retries") - _connected.value = false - session = null - } + Log.e(TAG, "[$chatConnectionType] connection failed after $RECONNECT_MAX_ATTEMPTS retries") + _connected.value = false + session = null + } } fun close() { 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..8130afde6 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 @@ -4,11 +4,4 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.message.RoomState -data class CommandContext( - val trigger: String, - val channel: UserName, - val channelId: UserId, - val roomState: RoomState, - val originalMessage: String, - val args: List -) +data class CommandContext(val trigger: String, val channel: UserName, val channelId: UserId, val roomState: RoomState, val originalMessage: String, 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..9688dd5a9 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 @@ -39,7 +39,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 03615fd70..efa9855bb 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 @@ -25,19 +25,15 @@ import org.koin.core.annotation.Single import java.util.UUID @Single -class TwitchCommandRepository( - private val helixApiClient: HelixApiClient, - private val authDataStore: AuthDataStore, -) { - +class TwitchCommandRepository(private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore) { fun isIrcCommand(trigger: String): Boolean = trigger in ALLOWED_IRC_COMMAND_TRIGGERS 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" } @@ -53,52 +49,85 @@ class TwitchCommandRepository( } suspend fun handleTwitchCommand(command: TwitchCommand, context: CommandContext): CommandResult { - val currentUserId = authDataStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( - command = command, - response = "You must be logged in to use the ${context.trigger} command" - ) + val currentUserId = + authDataStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( + command = command, + response = "You must be logged in to use the ${context.trigger} command", + ) 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) } } @@ -108,9 +137,10 @@ class TwitchCommandRepository( } 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 = "No user matching that username.") + } val request = WhisperRequestDto(args.drop(1).joinToString(separator = " ")) val result = helixApiClient.postWhisper(currentUserId, targetId, request) return result.fold( @@ -118,7 +148,7 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to send whisper - ${it.toErrorMessage(command)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -129,13 +159,14 @@ class TwitchCommandRepository( } 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( @@ -143,29 +174,30 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to send announcement - ${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 = "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.") } - 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) } - ) - } + 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 { val args = context.args @@ -173,9 +205,10 @@ class TwitchCommandRepository( return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Grant moderation status to a user.") } - 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 = "No user matching that username.") + } val targetId = target.id val targetUser = target.displayName @@ -184,7 +217,7 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to add channel moderator - ${it.toErrorMessage(command, targetUser)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -194,9 +227,10 @@ class TwitchCommandRepository( return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Revoke moderation status from a user.") } - 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 = "No user matching that username.") + } val targetId = target.id val targetUser = target.displayName @@ -205,27 +239,28 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to remove channel moderator - ${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 = "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.") } - }, - onFailure = { - val response = "Failed to list VIPs - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) } - ) - } + }, + onFailure = { + val response = "Failed to list VIPs - ${it.toErrorMessage(command)}" + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) private suspend fun addVip(command: TwitchCommand, context: CommandContext): CommandResult { val args = context.args @@ -233,9 +268,10 @@ class TwitchCommandRepository( return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Grant VIP status to a user.") } - 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 = "No user matching that username.") + } val targetId = target.id val targetUser = target.displayName @@ -244,7 +280,7 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to add VIP - ${it.toErrorMessage(command, targetUser)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -254,9 +290,10 @@ class TwitchCommandRepository( return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Revoke VIP status from a user.") } - 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 = "No user matching that username.") + } val targetId = target.id val targetUser = target.displayName @@ -265,21 +302,23 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to remove VIP - ${it.toErrorMessage(command, targetUser)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } 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. " + + 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) } - 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 = "No user matching that username.") + } if (target.id == currentUserId) { return CommandResult.AcceptedTwitchCommand(command, response = "Failed to ban user - You cannot ban yourself.") @@ -297,7 +336,7 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to ban user - ${it.toErrorMessage(command, targetUser)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -308,9 +347,10 @@ class TwitchCommandRepository( 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 = "No user matching that username.") + } val targetId = target.id return helixApiClient.deleteBan(context.channelId, currentUserId, targetId).fold( @@ -318,13 +358,14 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to unban user - ${it.toErrorMessage(command, target.displayName)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } private suspend fun timeoutUser(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { val args = context.args - val usageResponse = "Usage: ${context.trigger} [duration][time unit] [reason] - " + + 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 " + @@ -335,9 +376,10 @@ class TwitchCommandRepository( 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 = "No user matching that username.") + } if (target.id == currentUserId) { return CommandResult.AcceptedTwitchCommand(command, response = "Failed to ban user - You cannot timeout yourself.") @@ -345,10 +387,11 @@ class TwitchCommandRepository( return CommandResult.AcceptedTwitchCommand(command, response = "Failed to ban user - You cannot timeout the 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 @@ -358,19 +401,17 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to timeout user - ${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 clearChat(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult = 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 { val args = context.args @@ -389,7 +430,7 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to delete chat messages - ${it.toErrorMessage(command)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -408,12 +449,16 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to change color to $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 } + val description = + context.args + .joinToString(separator = " ") + .take(140) + .ifBlank { null } val request = MarkerRequestDto(context.channelId, description) return helixApiClient.postMarker(request).fold( @@ -425,7 +470,7 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to create stream marker - ${it.toErrorMessage(command)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -440,7 +485,8 @@ class TwitchCommandRepository( val request = CommercialRequestDto(context.channelId, length) return helixApiClient.postCommercial(request).fold( onSuccess = { result -> - val response = "Starting ${result.length} second long commercial break. " + + 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." CommandResult.AcceptedTwitchCommand(command, response) @@ -448,7 +494,7 @@ class TwitchCommandRepository( onFailure = { val response = "Failed to start commercial - ${it.toErrorMessage(command)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -459,39 +505,40 @@ class TwitchCommandRepository( return CommandResult.AcceptedTwitchCommand(command, response = usage) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "Invalid username: ${args.first()}") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = "Invalid username: ${args.first()}") + } 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) - } + }, ) } - private suspend fun cancelRaid(command: TwitchCommand, context: CommandContext): CommandResult { - return helixApiClient.deleteRaid(context.channelId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You cancelled the raid.") }, - onFailure = { - val response = "Failed to cancel the raid - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) - } - ) - } + private suspend fun cancelRaid(command: TwitchCommand, context: CommandContext): CommandResult = helixApiClient.deleteRaid(context.channelId).fold( + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You cancelled the raid.") }, + onFailure = { + val response = "Failed to cancel the raid - ${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). " + + 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 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.") @@ -572,7 +619,8 @@ class TwitchCommandRepository( 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). " + + 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) } @@ -601,16 +649,14 @@ class TwitchCommandRepository( currentUserId: UserId, context: CommandContext, request: ChatSettingsRequestDto, - formatRange: ((IntRange) -> String)? = null - ): 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) - } - ) - } + formatRange: ((IntRange) -> String)? = null, + ): CommandResult = 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 @@ -618,16 +664,17 @@ class TwitchCommandRepository( return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Sends a shoutout to the specified Twitch user.") } - 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 = "No user matching that username.") + } return helixApiClient.postShoutout(context.channelId, target.id, currentUserId).fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "Sent shoutout to ${target.displayName}") }, onFailure = { val response = "Failed to send shoutout - ${it.toErrorMessage(command)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -637,16 +684,17 @@ class TwitchCommandRepository( return helixApiClient.putShieldMode(context.channelId, currentUserId, request).fold( onSuccess = { - val response = when { - it.isActive -> "Shield mode was activated." - else -> "Shield mode was deactivated." - } + val response = + when { + it.isActive -> "Shield mode was activated." + else -> "Shield mode was deactivated." + } CommandResult.AcceptedTwitchCommand(command, response) }, onFailure = { val response = "Failed to update shield mode - ${it.toErrorMessage(command)}" CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } @@ -657,46 +705,137 @@ class TwitchCommandRepository( } 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 -> { + "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 -> { 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." } + } + HelixError.MessageAlreadyProcessed -> { + "The message has already been processed." } - HelixError.MessageAlreadyProcessed -> "The message has already been processed." - HelixError.MessageNotFound -> "The target message was not found." - HelixError.MessageTooLarge -> "Your message was too long." - HelixError.ChatMessageRateLimited -> "You are being rate-limited. Try again in a moment." - HelixError.Unknown -> GENERIC_ERROR_MESSAGE + HelixError.MessageNotFound -> { + "The target message was not found." + } + + HelixError.MessageTooLarge -> { + "Your message was too long." + } + + HelixError.ChatMessageRateLimited -> { + "You are being rate-limited. Try again in a moment." + } + + HelixError.Unknown -> { + GENERIC_ERROR_MESSAGE + } } } @@ -704,39 +843,43 @@ 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", - ) - - 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/ChatMessageEmoteType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmoteType.kt index 603584602..4b2851c53 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,7 +5,6 @@ import com.flxrs.dankchat.data.DisplayName import kotlinx.parcelize.Parcelize sealed interface ChatMessageEmoteType : Parcelable { - @Parcelize object TwitchEmote : ChatMessageEmoteType @@ -32,11 +31,11 @@ sealed interface ChatMessageEmoteType : Parcelable { } 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 index 5dcfe1d0a..4200df2c9 100644 --- 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 @@ -2,15 +2,6 @@ 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 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, -) +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..4ef2c2db1 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 @@ -51,16 +51,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 5193886e8..73a9d805c 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 @@ -1,19 +1,8 @@ package com.flxrs.dankchat.data.twitch.emote -data class GenericEmote( - val code: String, - val url: String, - val lowResUrl: String, - val id: String, - val scale: Int, - val emoteType: EmoteType, - val isOverlayEmote: Boolean = false, -) : Comparable { - override fun toString(): String { - return code - } +data class GenericEmote(val code: String, val url: String, val lowResUrl: String, val id: String, val scale: Int, val emoteType: EmoteType, val isOverlayEmote: Boolean = false) : + Comparable { + override fun toString(): String = code - override fun compareTo(other: GenericEmote): Int { - return code.compareTo(other.code) - } + 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 index ed6423d11..9ad457540 100644 --- 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 @@ -20,6 +20,5 @@ data class AutomodMessage( 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/HighlightState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/HighlightState.kt index b26220875..2fd4e2c78 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 @@ -1,9 +1,6 @@ package com.flxrs.dankchat.data.twitch.message -data class Highlight( - val type: HighlightType, - val customColor: Int? = null -) { +data class Highlight(val type: HighlightType, val customColor: Int? = null) { val isMention = type in MENTION_TYPES val shouldNotify = type == HighlightType.Notification @@ -13,7 +10,9 @@ 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) { 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 a37633ead..0dd104033 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 @@ -11,6 +11,7 @@ sealed class Message { abstract 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?) open val emoteData: EmoteData? = null @@ -19,12 +20,13 @@ sealed class Message { 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) { 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 } } @@ -40,21 +42,20 @@ sealed class Message { if (pairs.isEmpty()) return@mapNotNull null // skip over invalid parsed data - val parsedPositions = pairs.mapNotNull positions@{ pos -> - val pair = pos.split('-') - if (pair.size != 2) return@positions null + val parsedPositions = + pairs.mapNotNull positions@{ pos -> + val pair = pos.split('-') + if (pair.size != 2) return@positions null - val start = pair[0].toIntOrNull() ?: return@positions null - val end = pair[1].toIntOrNull() ?: return@positions null + val start = pair[0].toIntOrNull() ?: return@positions null + val end = pair[1].toIntOrNull() ?: return@positions null - // be extra safe in case twitch sends invalid emote ranges :) - start.coerceAtLeast(minimumValue = 0)..end.coerceAtMost(message.length) - } + // be extra safe in case twitch sends invalid emote ranges :) + start.coerceAtLeast(minimumValue = 0)..end.coerceAtMost(message.length) + } EmoteWithPositions(id, parsedPositions) } } } } - - 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..f6839ab6c 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 @@ -1,8 +1,3 @@ package com.flxrs.dankchat.data.twitch.message -data class MessageThread( - val rootMessageId: String, - val rootMessage: PrivMessage, - val replies: List, - val participated: Boolean -) +data class MessageThread(val rootMessageId: String, val rootMessage: PrivMessage, val replies: List, 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..1234cbf27 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 @@ -2,9 +2,4 @@ package com.flxrs.dankchat.data.twitch.message import com.flxrs.dankchat.data.UserName -data class MessageThreadHeader( - val rootId: String, - val name: UserName, - val message: String, - val participated: Boolean -) +data class MessageThreadHeader(val rootId: String, val name: UserName, val message: String, 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 ab914a45e..663733666 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 @@ -79,15 +79,13 @@ data class ModerationMessage( val fullReason = reason.orEmpty() return when { fullReason.length > 50 -> "${fullReason.take(50)}…" - else -> fullReason + else -> fullReason }.takeIf { it.isNotEmpty() } } - private fun countSuffix(): TextResource { - return when { - stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) - else -> TextResource.Plain("") - } + private fun countSuffix(): TextResource = when { + stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) + else -> TextResource.Plain("") } private fun formatMinutesDuration(minutes: Int): TextResource { @@ -101,20 +99,21 @@ data class ModerationMessage( } 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 - } + 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])) + 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])) } @@ -124,130 +123,236 @@ data class ModerationMessage( val dur = duration.orEmpty() val source = sourceBroadcasterDisplay.toString() - val message = when (action) { - Action.Timeout -> 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)) + val message = + when (action) { + Action.Timeout -> { + 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)) + } + } + } + } } } - 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.Untimeout -> TextResource.Res(R.string.mod_untimeout, persistentListOf(creator, target)) + Action.Unban -> { + TextResource.Res(R.string.mod_unban, 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)) + 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)) + } + } } } - 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.Clear -> { + when (creatorUserDisplay) { + null -> TextResource.Res(R.string.mod_clear_no_creator) + else -> TextResource.Res(R.string.mod_clear_by_creator, persistentListOf(creator)) } } - } - 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.Vip -> { + TextResource.Res(R.string.mod_vip_added, persistentListOf(creator, target)) + } + + Action.Unvip -> { + TextResource.Res(R.string.mod_vip_removed, 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)) + 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)) } + } - 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.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)) + } + + Action.Followers -> { + when (val mins = durationInt?.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.Clear -> when (creatorUserDisplay) { - null -> TextResource.Res(R.string.mod_clear_no_creator) - else -> TextResource.Res(R.string.mod_clear_by_creator, persistentListOf(creator)) - } + Action.FollowersOff -> { + TextResource.Res(R.string.mod_followers_off, persistentListOf(creator)) + } + + Action.UniqueChat -> { + TextResource.Res(R.string.mod_unique_chat_on, 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.UniqueChatOff -> { + TextResource.Res(R.string.mod_unique_chat_off, persistentListOf(creator)) + } - 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.Slow -> { + when (val secs = durationInt) { + null -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creator)) + else -> TextResource.Res(R.string.mod_slow_on_duration, persistentListOf(creator, formatSecondsDuration(secs))) + } + } - 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)) + Action.SlowOff -> { + TextResource.Res(R.string.mod_slow_off, persistentListOf(creator)) + } - Action.Followers -> when (val mins = durationInt?.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.Subscribers -> { + TextResource.Res(R.string.mod_subscribers_on, persistentListOf(creator)) + } - 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)) + Action.SubscribersOff -> { + TextResource.Res(R.string.mod_subscribers_off, persistentListOf(creator)) + } - Action.Slow -> when (val secs = durationInt) { - null -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creator)) - else -> TextResource.Res(R.string.mod_slow_on_duration, persistentListOf(creator, formatSecondsDuration(secs))) - } + Action.SharedTimeout -> { + 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.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)) + Action.SharedUntimeout -> { + TextResource.Res(R.string.mod_shared_untimeout, persistentListOf(creator, target, source)) + } - Action.SharedTimeout -> 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.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.SharedUntimeout -> TextResource.Res(R.string.mod_shared_untimeout, persistentListOf(creator, target, source)) + Action.SharedUnban -> { + TextResource.Res(R.string.mod_shared_unban, 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.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.SharedUnban -> TextResource.Res(R.string.mod_shared_unban, persistentListOf(creator, target, source)) + Action.AddBlockedTerm -> { + TextResource.Res(R.string.automod_moderation_added_blocked_term, persistentListOf(creator, quotedTermsOrBlank)) + } - 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.AddPermittedTerm -> { + TextResource.Res(R.string.automod_moderation_added_permitted_term, persistentListOf(creator, quotedTermsOrBlank)) } - } - 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)) - } + 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)) + else -> TextResource.Res(R.string.mod_message_with_count, persistentListOf(message, count)) } } @@ -262,11 +367,12 @@ data class ModerationMessage( val duration = durationSeconds?.let { DateTimeUtils.formatSeconds(it) } val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() val id = tags["id"] ?: "clearchat-$ts-$channel-${target ?: "all"}" - val action = when { - target == null -> Action.Clear - durationSeconds == null -> Action.Ban - else -> Action.Timeout - } + val action = + when { + target == null -> Action.Clear + durationSeconds == null -> Action.Ban + else -> Action.Timeout + } return ModerationMessage( timestamp = ts, @@ -356,123 +462,133 @@ data class ModerationMessage( private fun parseDuration(seconds: Int?, data: ModerationActionData): String? = when (data.moderationAction) { ModerationActionType.Timeout -> seconds?.let { DateTimeUtils.formatSeconds(seconds) } - else -> null + else -> null } private fun parseDuration(timestamp: Instant, data: ChannelModerateDto): Int? = when (data.action) { - ChannelModerateAction.Timeout -> data.timeout?.let { it.expiresAt.epochSeconds - timestamp.epochSeconds }?.toInt() + 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 + ChannelModerateAction.Followers -> data.followers?.followDurationMinutes + ChannelModerateAction.Slow -> data.slow?.waitTimeSeconds + 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.SharedChatTimeout -> data.sharedChatTimeout?.reason - ChannelModerateAction.Warn -> data.warn?.let { listOfNotNull(it.reason).plus(it.chatRulesCited.orEmpty()).joinToString() } + 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() } + ChannelModerateAction.AddBlockedTerm, ChannelModerateAction.AddPermittedTerm, ChannelModerateAction.RemoveBlockedTerm, - ChannelModerateAction.RemovePermittedTerm -> data.automodTerms?.terms?.joinToString(" and ") { "\"$it\"" } + ChannelModerateAction.RemovePermittedTerm, + -> data.automodTerms?.terms?.joinToString(" and ") { "\"$it\"" } - else -> null + 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 + ModerationActionType.Timeout -> Action.Timeout 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.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 - ChannelModerateAction.AddBlockedTerm -> Action.AddBlockedTerm - ChannelModerateAction.AddPermittedTerm -> Action.AddPermittedTerm - ChannelModerateAction.RemoveBlockedTerm -> Action.RemoveBlockedTerm + 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") + 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..28b37e879 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 + 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..01a9c09e5 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 @@ -30,7 +30,8 @@ data class PointRedemptionMessage( name = data.user.name, displayName = data.user.displayName, title = data.reward.title, - rewardImageUrl = data.reward.images?.imageLarge + rewardImageUrl = + data.reward.images?.imageLarge ?: data.reward.defaultImages.imageLarge, cost = data.reward.cost, 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 a26149a41..fd41f1569 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 @@ -34,20 +34,21 @@ 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 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() { - 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()) - } + 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 @@ -55,20 +56,24 @@ data class PrivMessage( 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, 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 63eb3e45c..67eceb46b 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 @@ -13,15 +13,15 @@ import kotlinx.collections.immutable.toImmutableList 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 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 @@ -35,13 +35,20 @@ data class RoomState( .filter { (it.key == RoomStateTag.FOLLOW && it.value >= 0) || it.value > 0 } .map { (tag, value) -> when (tag) { - RoomStateTag.FOLLOW -> when (value) { - 0 -> "follow" - else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" + RoomStateTag.FOLLOW -> { + when (value) { + 0 -> "follow" + else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" + } + } + + RoomStateTag.SLOW -> { + "slow(${DateTimeUtils.formatSeconds(value)})" } - RoomStateTag.SLOW -> "slow(${DateTimeUtils.formatSeconds(value)})" - else -> tag.name.lowercase() + else -> { + tag.name.lowercase() + } } }.joinToString() @@ -49,13 +56,27 @@ data class RoomState( .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))) + 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() 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/SystemMessageType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessageType.kt index 641959114..317d777e0 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 @@ -7,34 +7,62 @@ import com.flxrs.dankchat.data.chat.ChatItem 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 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..476966d5d 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 @@ -13,5 +13,3 @@ fun UserDisplayEntity.toUserDisplay() = UserDisplay( @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 a8de6f1af..5be1c6728 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 @@ -15,19 +15,19 @@ data class UserNoticeMessage( val childMessage: PrivMessage?, val tags: Map, ) : Message() { - override val emoteData: EmoteData? = childMessage?.emoteData override val badgeData: 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", + ) fun parseUserNotice(message: IrcMessage, findChannel: (UserId) -> UserName?, historic: Boolean = false): UserNoticeMessage? = with(message) { var msgId = tags["msg-id"] @@ -49,26 +49,36 @@ 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 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, 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..4ccdb1801 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 @@ -39,9 +39,9 @@ data class WhisperMessage( override val emoteData: EmoteData = EmoteData(originalMessage, WHISPER_CHANNEL, parseEmoteTag(originalMessage, rawEmotes)), override val badgeData: BadgeData = 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) { val name = prefix.substringBefore('!') val displayName = tags["display-name"] ?: name @@ -64,20 +64,27 @@ data class WhisperMessage( message = message, rawEmotes = emoteTag, rawBadges = tags["badges"], - rawBadgeInfo = tags["badge-info"] + 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 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}" } - } + 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 @@ -96,7 +103,6 @@ data class WhisperMessage( ) } } - } val WhisperMessage.senderAliasOrFormattedName: String 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 5cf6746c7..2b8a3f1ae 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 @@ -48,13 +48,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @OptIn(DelicateCoroutinesApi::class) -class PubSubConnection( - val tag: String, - private val client: HttpClient, - private val scope: CoroutineScope, - private val oAuth: String, - private val jsonFormat: Json, -) { +class PubSubConnection(val tag: String, private val client: HttpClient, private val scope: CoroutineScope, private val oAuth: String, private val jsonFormat: Json) { @Volatile private var session: DefaultClientWebSocketSession? = null private var connectionJob: Job? = null @@ -141,7 +135,6 @@ class PubSubConnection( return@launch } Log.i(TAG, "[PubSub $tag] reconnecting after server request") - } catch (t: CancellationException) { throw t } catch (t: Throwable) { @@ -188,7 +181,7 @@ class PubSubConnection( fun unlistenByChannel(channel: UserName) { val toUnlisten = topics.filter { - it is PubSubTopic.PointRedemptions && it.channelName == channel || it is PubSubTopic.ModeratorActions && it.channelName == channel + (it is PubSubTopic.PointRedemptions && it.channelName == channel) || (it is PubSubTopic.ModeratorActions && it.channelName == channel) } unlisten(toUnlisten.toSet()) } @@ -256,20 +249,21 @@ class PubSubConnection( val json = JSONObject(text) val type = json.optString("type").ifBlank { return false } when (type) { - "PONG" -> awaitingPong = false + "PONG" -> awaitingPong = false + "RECONNECT" -> { Log.i(TAG, "[PubSub $tag] server requested reconnect") return true } - "RESPONSE" -> { + "RESPONSE" -> { val error = json.optString("error") if (error.isNotBlank()) { Log.w(TAG, "[PubSub $tag] RESPONSE error: $error") } } - "MESSAGE" -> { + "MESSAGE" -> { val data = json.optJSONObject("data") ?: return false val topic = data.optString("topic").ifBlank { return false } val message = data.optString("message").ifBlank { return false } @@ -277,7 +271,7 @@ class PubSubConnection( val messageTopic = messageObject.optString("type") val match = topics.find { topic == it.topic } ?: return false val pubSubMessage = when (match) { - is PubSubTopic.Whispers -> { + is PubSubTopic.Whispers -> { if (messageTopic !in listOf("whisper_sent", "whisper_received")) { return false } @@ -296,13 +290,13 @@ class PubSubConnection( 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" -> { + "moderator_added" -> { val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false val timestamp = Clock.System.now() PubSubMessage.ModeratorAction( @@ -316,8 +310,8 @@ class PubSubConnection( creatorUserId = parsedMessage.data.creatorUserId, creator = parsedMessage.data.creator, createdAt = timestamp.toString(), - msgId = null - ) + msgId = null, + ), ) } @@ -328,7 +322,7 @@ class PubSubConnection( } val timestamp = when { parsedMessage.data.createdAt.isEmpty() -> Clock.System.now() - else -> Instant.parse(parsedMessage.data.createdAt) + else -> Instant.parse(parsedMessage.data.createdAt) } PubSubMessage.ModeratorAction( timestamp = timestamp, @@ -339,11 +333,11 @@ class PubSubConnection( creatorUserId = parsedMessage.data.creatorUserId?.ifBlank { null }, targetUserId = parsedMessage.data.targetUserId?.ifBlank { null }, targetUserName = parsedMessage.data.targetUserName?.ifBlank { null }, - ) + ), ) } - else -> return false + else -> return false } } } @@ -353,9 +347,7 @@ class PubSubConnection( 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 } 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..85f392f94 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 @@ -2,8 +2,11 @@ package com.flxrs.dankchat.data.twitch.pubsub sealed interface 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 c46a98a64..86466c9dc 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 @@ -40,9 +40,10 @@ class PubSubManager( dispatchersProvider: DispatchersProvider, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - private val client = httpClient.config { - install(WebSockets) - } + private val client = + httpClient.config { + install(WebSockets) + } private val connections = mutableListOf() private val collectJobs = mutableListOf() private val receiveChannel = CoroutineChannel(capacity = CoroutineChannel.BUFFERED) @@ -82,10 +83,11 @@ class PubSubManager( fun reconnectIfNecessary() = resetCollectionWith { reconnectIfNecessary() } 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 @@ -110,9 +112,10 @@ class PubSubManager( private fun listen(topics: Set) { val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return - val remainingTopics = connections.fold(topics) { acc, conn -> - conn.listen(acc) - } + val remainingTopics = + connections.fold(topics) { acc, conn -> + conn.listen(acc) + } if (remainingTopics.isEmpty() || connections.size >= PubSubConnection.MAX_CONNECTIONS) { return @@ -124,13 +127,14 @@ class PubSubManager( .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, - ) + val connection = + PubSubConnection( + tag = "#${connections.size}", + client = client, + scope = this, + oAuth = oAuth, + jsonFormat = json, + ) connection.connect(initialTopics = topics.toSet()) connections += connection collectJobs += launch { connection.collectEvents() } @@ -149,11 +153,12 @@ class PubSubManager( collectJobs.forEach { it.cancel() } collectJobs.clear() collectJobs.addAll( - elements = connections + elements = + connections .map { conn -> conn.action() launch { conn.collectEvents() } - } + }, ) } @@ -161,7 +166,7 @@ class PubSubManager( events.collect { when (it) { is PubSubEvent.Message -> receiveChannel.send(it.message) - else -> Unit + else -> Unit } } } 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..9bcc55274 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 @@ -8,18 +8,9 @@ import com.flxrs.dankchat.data.twitch.pubsub.dto.whisper.WhisperData import kotlin.time.Instant sealed interface PubSubMessage { - data class PointRedemption( - val timestamp: Instant, - val channelName: UserName, - val channelId: UserId, - val data: PointRedemptionData - ) : PubSubMessage + data class PointRedemption(val timestamp: Instant, val channelName: UserName, val channelId: UserId, val data: PointRedemptionData) : PubSubMessage data class Whisper(val data: WhisperData) : PubSubMessage - data class ModeratorAction( - val timestamp: Instant, - val channelId: UserId, - val data: ModerationActionData - ) : PubSubMessage + data class ModeratorAction(val timestamp: Instant, val channelId: UserId, 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..48ca1f1eb 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 @@ -5,6 +5,8 @@ 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") } 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..5e7364a30 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 @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PubSubDataMessage( - val type: String, - val data: T -) +data class PubSubDataMessage(val type: String, 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 index f6d3007ba..bd439e848 100644 --- 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 @@ -6,7 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PubSubDataObjectMessage( - val type: String, - @SerialName("data_object") val data: T -) +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 f75bed986..5f7823cf7 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 @@ -6,7 +6,4 @@ import kotlin.time.Instant @Keep @Serializable -data class PointRedemption( - val redemption: PointRedemptionData, - val timestamp: Instant, -) +data class PointRedemption(val redemption: PointRedemptionData, val timestamp: Instant) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt index 0a334f898..08ac29f44 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt @@ -5,8 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PointRedemptionData( - val id: String, - val user: PointRedemptionUser, - val reward: PointRedemptionReward, -) +data class PointRedemptionData(val id: String, val user: PointRedemptionUser, val reward: PointRedemptionReward) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt index 8a59b4d83..a4b987ad2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt @@ -6,8 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PointRedemptionImages( - @SerialName("url_1x") val imageSmall: String, - @SerialName("url_2x") val imageMedium: String, - @SerialName("url_4x") val imageLarge: String, -) +data class PointRedemptionImages(@SerialName("url_1x") val imageSmall: String, @SerialName("url_2x") val imageMedium: String, @SerialName("url_4x") val imageLarge: String) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt index a2ee1af9c..151b07d82 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt @@ -9,8 +9,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PointRedemptionUser( - val id: UserId, - @SerialName("login") val name: UserName, - @SerialName("display_name") val displayName: DisplayName, -) +data class PointRedemptionUser(val id: UserId, @SerialName("login") val name: UserName, @SerialName("display_name") val displayName: DisplayName) 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 index 7be513d17..5cb5fd240 100644 --- 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 @@ -5,7 +5,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class WhisperDataBadge( - val id: String, - val version: String, -) +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 index 74468a8da..c67513f20 100644 --- 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 @@ -6,8 +6,4 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class WhisperDataEmote( - @SerialName("emote_id") val id: String, - val start: Int, - val end: Int, -) +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 index 18e15a94b..cd063e869 100644 --- 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 @@ -9,9 +9,4 @@ 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, -) +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/di/ConnectionModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt index 6191f2a42..ea4358db6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt @@ -9,24 +9,18 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single data object ReadConnection + data object WriteConnection @Module class ConnectionModule { - @Single @Named(type = ReadConnection::class) - fun provideReadConnection( - httpClient: HttpClient, - dispatchersProvider: DispatchersProvider, - authDataStore: AuthDataStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Read, httpClient, authDataStore, dispatchersProvider) + fun provideReadConnection(httpClient: HttpClient, dispatchersProvider: DispatchersProvider, authDataStore: AuthDataStore): ChatConnection = + ChatConnection(ChatConnectionType.Read, httpClient, authDataStore, dispatchersProvider) @Single @Named(type = WriteConnection::class) - fun provideWriteConnection( - httpClient: HttpClient, - dispatchersProvider: DispatchersProvider, - authDataStore: AuthDataStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Write, httpClient, authDataStore, dispatchersProvider) + fun provideWriteConnection(httpClient: HttpClient, dispatchersProvider: DispatchersProvider, authDataStore: AuthDataStore): ChatConnection = + ChatConnection(ChatConnectionType.Write, httpClient, authDataStore, dispatchersProvider) } 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 ca53ea548..3ac822f06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -37,6 +37,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration data object WebSocketOkHttpClient + data object UploadOkHttpClient @Module @@ -54,13 +55,15 @@ class NetworkModule { @Single @Named(type = WebSocketOkHttpClient::class) - fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder() + fun provideOkHttpClient(): OkHttpClient = OkHttpClient + .Builder() .callTimeout(20.seconds.toJavaDuration()) .build() @Single @Named(type = UploadOkHttpClient::class) - fun provideUploadOkHttpClient(): OkHttpClient = OkHttpClient.Builder() + fun provideUploadOkHttpClient(): OkHttpClient = OkHttpClient + .Builder() .callTimeout(60.seconds.toJavaDuration()) .build() @@ -76,11 +79,12 @@ class NetworkModule { fun provideKtorClient(json: Json): HttpClient = HttpClient(OkHttp) { install(Logging) { level = LogLevel.INFO - logger = object : Logger { - override fun log(message: String) { - Log.v("HttpClient", message) + logger = + object : Logger { + override fun log(message: String) { + Log.v("HttpClient", message) + } } - } } install(HttpCache) install(UserAgent) { @@ -97,71 +101,91 @@ class NetworkModule { } @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, 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) + fun provideHelixApi(ktorClient: HttpClient, authDataStore: AuthDataStore, helixApiStats: HelixApiStats, startupValidationHolder: StartupValidationHolder) = HelixApi( + ktorClient.config { + defaultRequest { + url(HELIX_BASE_URL) + header("Client-ID", authDataStore.clientId) } - } - }, authDataStore, startupValidationHolder) + 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 index 3272f8cd6..59194d3f7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -8,8 +8,8 @@ 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.state.ChannelLoadingState 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 @@ -38,9 +38,8 @@ class ChannelDataCoordinator( private val preferenceStore: DankChatPreferenceStore, private val startupValidationHolder: StartupValidationHolder, private val streamDataRepository: StreamDataRepository, - dispatchersProvider: DispatchersProvider + dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private var globalLoadJob: Job? = null @@ -59,7 +58,7 @@ class ChannelDataCoordinator( chatMessageRepository.addSystemMessage(event.channel, SystemMessageType.ChannelSevenTVEmoteSetChanged(event.actorName, event.emoteSetName)) } - is DataUpdateEventMessage.EmoteSetUpdated -> { + 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)) } @@ -76,8 +75,7 @@ class ChannelDataCoordinator( when (current) { is GlobalLoadingState.Failed -> current.copy(chatFailures = chatFailures) is GlobalLoadingState.Loaded -> GlobalLoadingState.Failed(chatFailures = chatFailures) - - else -> current + else -> current } } } @@ -85,10 +83,8 @@ class ChannelDataCoordinator( } } - fun getChannelLoadingState(channel: UserName): StateFlow { - return channelStates.getOrPut(channel) { - MutableStateFlow(ChannelLoadingState.Idle) - } + fun getChannelLoadingState(channel: UserName): StateFlow = channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) } /** @@ -102,9 +98,10 @@ class ChannelDataCoordinator( private suspend fun loadChannelDataSuspend(channel: UserName) { startupValidationHolder.awaitResolved() - val stateFlow = channelStates.getOrPut(channel) { - MutableStateFlow(ChannelLoadingState.Idle) - } + val stateFlow = + channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) + } stateFlow.value = ChannelLoadingState.Loading stateFlow.value = channelDataLoader.loadChannelData(channel) chatMessageRepository.reparseAllEmotesAndBadges() @@ -114,50 +111,58 @@ class ChannelDataCoordinator( * Load global data (once at startup) */ 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) - } + globalLoadJob = + scope.launch { + _globalLoadingState.value = GlobalLoadingState.Loading + dataRepository.clearDataLoadingFailures() - globalDataLoader.loadAuthGlobalData() + // Phase 1: Non-auth data (3rd-party emotes, DankChat badges) — loads immediately + globalDataLoader.loadGlobalData() 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) } + // 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) } - firstPageLoaded.await() + + 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, - ) + 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, + ) + } + } } - } } /** @@ -194,7 +199,8 @@ class ChannelDataCoordinator( val userId = authDataStore.userIdString ?: return@launch val firstPageLoaded = CompletableDeferred() launch { - globalDataLoader.loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } + globalDataLoader + .loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } .onSuccess { chatMessageRepository.reparseAllEmotesAndBadges() } .onFailure { firstPageLoaded.complete(Unit) } } @@ -221,36 +227,67 @@ class ChannelDataCoordinator( 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) } + 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() } - 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) + 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) { @@ -259,19 +296,26 @@ class ChannelDataCoordinator( } dataResults.awaitAll() - channelsToRetry.map { channel -> - async { loadChannelDataSuspend(channel) } - }.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, - ) - } + _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 index 9ffb82004..e29738499 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -23,9 +23,8 @@ class ChannelDataLoader( private val chatMessageRepository: ChatMessageRepository, private val channelRepository: ChannelRepository, private val getChannelsUseCase: GetChannelsUseCase, - private val dispatchersProvider: DispatchersProvider + private val dispatchersProvider: DispatchersProvider, ) { - suspend fun loadChannelData(channel: UserName): ChannelLoadingState { return try { // Phase 1: No auth needed — create flows and load message history @@ -34,30 +33,33 @@ class ChannelDataLoader( chatRepository.loadRecentMessagesIfEnabled(channel) // Phase 2: Needs channel info (Helix or IRC fallback) for emotes/badges - val channelInfo = channelRepository.getChannel(channel) - ?: getChannelsUseCase(listOf(channel)).firstOrNull() + 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) } + val failures = + withContext(dispatchersProvider.io) { + val badgesResult = async { loadChannelBadges(channel, channelInfo.id) } + val emotesResults = async { loadChannelEmotes(channel, channelInfo) } - listOfNotNull( - badgesResult.await(), - *emotesResults.await().toTypedArray(), - ) - } + 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 - } + 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) } @@ -65,59 +67,52 @@ class ChannelDataLoader( when { failures.isEmpty() -> ChannelLoadingState.Loaded - else -> ChannelLoadingState.Failed(failures) + else -> ChannelLoadingState.Failed(failures) } } catch (_: Exception) { ChannelLoadingState.Failed(emptyList()) } } - suspend fun loadChannelBadges( - channel: UserName, - channelId: UserId - ): ChannelLoadingFailure.Badges? { - return dataRepository.loadChannelBadges(channel, channelId).fold( - onSuccess = { null }, - onFailure = { ChannelLoadingFailure.Badges(channel, channelId, it) } - ) - } + 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 { - return withContext(dispatchersProvider.io) { - val bttvResult = async { + 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) } + onFailure = { ChannelLoadingFailure.BTTVEmotes(channel, it) }, ) } - val ffzResult = async { + val ffzResult = + async { dataRepository.loadChannelFFZEmotes(channel, channelInfo.id).fold( onSuccess = { null }, - onFailure = { ChannelLoadingFailure.FFZEmotes(channel, it) } + onFailure = { ChannelLoadingFailure.FFZEmotes(channel, it) }, ) } - val sevenTvResult = async { + val sevenTvResult = + async { dataRepository.loadChannelSevenTVEmotes(channel, channelInfo.id).fold( onSuccess = { null }, - onFailure = { ChannelLoadingFailure.SevenTVEmotes(channel, it) } + onFailure = { ChannelLoadingFailure.SevenTVEmotes(channel, it) }, ) } - val cheermotesResult = async { + val cheermotesResult = + async { dataRepository.loadChannelCheermotes(channel, channelInfo.id).fold( onSuccess = { null }, - onFailure = { ChannelLoadingFailure.Cheermotes(channel, it) } + onFailure = { ChannelLoadingFailure.Cheermotes(channel, it) }, ) } - listOfNotNull( - bttvResult.await(), - ffzResult.await(), - sevenTvResult.await(), - cheermotesResult.await(), - ) - } + 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 index 75ea3c228..f31aa4b01 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt @@ -24,7 +24,6 @@ class ConnectionCoordinator( private val appLifecycleListener: AppLifecycleListener, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) fun initialize() { @@ -32,7 +31,7 @@ class ConnectionCoordinator( val result = authStateCoordinator.validateOnStartup() when (result) { is AuthEvent.TokenInvalid -> Unit - else -> chatConnector.connectAndJoin(chatChannelProvider.channels.value.orEmpty()) + else -> chatConnector.connectAndJoin(chatChannelProvider.channels.value.orEmpty()) } } @@ -41,7 +40,10 @@ class ConnectionCoordinator( var wasInBackground = false appLifecycleListener.appState.collect { state -> when (state) { - is AppLifecycle.Background -> wasInBackground = true + is AppLifecycle.Background -> { + wasInBackground = true + } + is AppLifecycle.Foreground -> { if (wasInBackground) { wasInBackground = false 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..bb8d30ee6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt @@ -14,30 +14,34 @@ import org.koin.core.annotation.Single @Single 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 +50,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 index 4298d36e4..4d5f952ef 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -17,37 +17,42 @@ class GlobalDataLoader( private val dataRepository: DataRepository, private val commandRepository: CommandRepository, private val ignoresRepository: IgnoresRepository, - private val dispatchersProvider: DispatchersProvider + private val dispatchersProvider: DispatchersProvider, ) { - suspend fun loadGlobalData(): List> = withContext(dispatchersProvider.io) { - val results = awaitAll( - async { loadDankChatBadges() }, - async { loadGlobalBTTVEmotes() }, - async { loadGlobalFFZEmotes() }, - async { loadGlobalSevenTVEmotes() }, - ) + 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() }, - ) + 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 { - return dataRepository.loadUserEmotes(userId, onFirstPageLoaded) - } + suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result = dataRepository.loadUserEmotes(userId, onFirstPageLoaded) } 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 aeb0fa43a..95b6998a8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -26,12 +26,7 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class DankChatPreferenceStore( - private val context: Context, - private val json: Json, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val authDataStore: AuthDataStore, -) { +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) private var channelRenames: String? @@ -43,11 +38,17 @@ class DankChatPreferenceStore( 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) } } @@ -91,19 +92,19 @@ class DankChatPreferenceStore( val secretDankerModeClicks: Int = SECRET_DANKER_MODE_CLICKS - val isLoggedInFlow: Flow = authDataStore.settings - .map { it.isLoggedIn } - .distinctUntilChanged() + val isLoggedInFlow: Flow = + authDataStore.settings + .map { it.isLoggedIn } + .distinctUntilChanged() - val currentUserAndDisplayFlow: Flow> = authDataStore.settings - .map { authDataStore.userName to authDataStore.displayName } - .distinctUntilChanged() + val currentUserAndDisplayFlow: Flow> = + authDataStore.settings + .map { authDataStore.userName to authDataStore.displayName } + .distinctUntilChanged() - 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 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 clearLogin() { @@ -132,11 +133,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) } 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 index 1a6aed724..6ba5f39f8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -45,9 +45,7 @@ import sh.calvin.autolinktext.TextRuleDefaults import sh.calvin.autolinktext.annotateString @Composable -fun AboutScreen( - onBack: () -> Unit, -) { +fun AboutScreen(onBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), @@ -63,7 +61,7 @@ fun AboutScreen( onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, ) { padding -> @@ -112,7 +110,7 @@ fun AboutScreen( text = license, modifier = Modifier.verticalScroll(rememberScrollState()), ) - } + }, ) } } 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 8f66811a1..7130cc1a3 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 @@ -4,7 +4,13 @@ import kotlinx.serialization.Serializable @Serializable enum class InputAction { - Search, LastMessage, Stream, ModActions, Fullscreen, HideInput, Debug + Search, + LastMessage, + Stream, + ModActions, + Fullscreen, + HideInput, + Debug, } @Serializable @@ -20,10 +26,13 @@ data class AppearanceSettings( val showChips: Boolean = true, val showChangelogs: Boolean = true, val showCharacterCounter: Boolean = false, - val inputActions: List = listOf( - InputAction.Stream, InputAction.ModActions, - InputAction.Search, InputAction.LastMessage, - ), + val inputActions: List = + listOf( + InputAction.Stream, + InputAction.ModActions, + InputAction.Search, + InputAction.LastMessage, + ), ) enum class ThemePreference { System, Dark, Light } 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 013689136..05267efc6 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 @@ -21,11 +21,7 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class AppearanceSettingsDataStore( - context: Context, - dispatchersProvider: DispatchersProvider, -) { - +class AppearanceSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { private enum class AppearancePreferenceKeys(override val id: Int) : PreferenceKeys { Theme(R.string.preference_theme_key), TrueDark(R.string.preference_true_dark_theme_key), @@ -39,59 +35,96 @@ 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 inputActions = settings - .map { it.inputActions } - .distinctUntilChanged() - val showCharacterCounter = settings - .map { it.showCharacterCounter } - .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() fun current() = currentSettings.value 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 index 32818d8a2..6e9cf66ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -60,9 +60,7 @@ import org.koin.compose.viewmodel.koinViewModel import kotlin.math.roundToInt @Composable -fun AppearanceSettingsScreen( - onBack: () -> Unit, -) { +fun AppearanceSettingsScreen(onBack: () -> Unit) { val viewModel = koinViewModel() val uiState = viewModel.settings.collectAsStateWithLifecycle().value @@ -70,7 +68,7 @@ fun AppearanceSettingsScreen( settings = uiState.settings, onInteraction = { viewModel.onInteraction(it) }, onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, - onBack = onBack + onBack = onBack, ) } @@ -94,7 +92,7 @@ private fun AppearanceSettingsContent( onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, ) { padding -> @@ -129,11 +127,7 @@ private fun AppearanceSettingsContent( } @Composable -private fun ComponentsCategory( - autoDisableInput: Boolean, - showCharacterCounter: Boolean, - onInteraction: (AppearanceSettingsInteraction) -> Unit, -) { +private fun ComponentsCategory(autoDisableInput: Boolean, showCharacterCounter: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit) { PreferenceCategory( title = stringResource(R.string.preference_components_group_title), ) { @@ -152,13 +146,7 @@ private fun ComponentsCategory( } @Composable -private fun DisplayCategory( - fontSize: Int, - keepScreenOn: Boolean, - lineSeparator: Boolean, - checkeredMessages: Boolean, - onInteraction: (AppearanceSettingsInteraction) -> Unit, -) { +private fun DisplayCategory(fontSize: Int, keepScreenOn: Boolean, lineSeparator: Boolean, checkeredMessages: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit) { PreferenceCategory( title = stringResource(R.string.preference_display_group_title), ) { @@ -194,11 +182,7 @@ private fun DisplayCategory( } @Composable -private fun ThemeCategory( - theme: ThemePreference, - trueDarkTheme: Boolean, - onInteraction: suspend (AppearanceSettingsInteraction) -> Unit, -) { +private fun ThemeCategory(theme: ThemePreference, trueDarkTheme: Boolean, onInteraction: suspend (AppearanceSettingsInteraction) -> Unit) { val scope = rememberCoroutineScope() val themeState = rememberThemeState(theme, trueDarkTheme, isSystemInDarkTheme()) PreferenceCategory( @@ -212,13 +196,13 @@ private fun ThemeCategory( values = themeState.values, entries = themeState.entries, selected = themeState.preference, - onChange ={ + onChange = { scope.launch { activity ?: return@launch onInteraction(Theme(it)) setDarkMode(it, activity) } - } + }, ) SwitchPreferenceItem( title = stringResource(R.string.preference_true_dark_theme_title), @@ -231,7 +215,7 @@ private fun ThemeCategory( onInteraction(TrueDarkTheme(it)) ActivityCompat.recreate(activity) } - } + }, ) } } @@ -275,22 +259,20 @@ private fun rememberThemeState(theme: ThemePreference, trueDark: Boolean, system } } -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 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) } 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 - } + 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/AppearanceSettingsState.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt index ade132d1e..10e156777 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -4,17 +4,23 @@ 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 } @Immutable -data class AppearanceSettingsUiState( - val settings: AppearanceSettings, -) +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 ce734dd58..ff4290bc3 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 @@ -11,29 +11,27 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class AppearanceSettingsViewModel( - private val dataStore: AppearanceSettingsDataStore, -) : ViewModel() { - - val settings = dataStore.settings - .map { AppearanceSettingsUiState(settings = it) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = AppearanceSettingsUiState(settings = dataStore.current()), - ) +class AppearanceSettingsViewModel(private val dataStore: AppearanceSettingsDataStore) : ViewModel() { + 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.CheckeredMessages -> dataStore.update { it.copy(checkeredMessages = interaction.value) } - is AppearanceSettingsInteraction.AutoDisableInput -> dataStore.update { it.copy(autoDisableInput = interaction.value) } - is AppearanceSettingsInteraction.ShowChangelogs -> dataStore.update { it.copy(showChangelogs = 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.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) } } } 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 4992e2d12..8fbcd606d 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 @@ -28,7 +28,6 @@ data class ChatSettings( val showChatModes: Boolean = true, val sharedChatMigration: Boolean = false, ) { - @Transient val visibleBadgeTypes = visibleBadges.map { BadgeType.entries[it.ordinal] } 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 31658f099..1a93cd5c5 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 @@ -30,11 +30,7 @@ import org.koin.core.annotation.Single import kotlin.time.Duration.Companion.seconds @Single -class ChatSettingsDataStore( - context: Context, - dispatchersProvider: DispatchersProvider, -) { - +class ChatSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { private enum class ChatPreferenceKeys(override val id: Int) : PreferenceKeys { Suggestions(R.string.preference_suggestions_key), SupibotSuggestions(R.string.preference_supibot_suggestions_key), @@ -56,123 +52,191 @@ 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.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) { + ChatPreferenceKeys.Suggestions -> { + acc.copy(suggestions = value.booleanOrDefault(acc.suggestions)) + } + + 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) + } + + 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) - 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, - ) + override suspend fun cleanUp() = Unit + } - ChatPreferenceKeys.VisibleEmotes -> acc.copy( - visibleEmotes = value.mappedStringSetOrDefault( - original = context.resources.getStringArray(R.array.emotes_entry_values), - enumEntries = VisibleThirdPartyEmotes.entries, - default = acc.visibleEmotes, - ) - ) + private val sharedChatMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: ChatSettings): Boolean = !currentData.sharedChatMigration - 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, - ) + override suspend fun migrate(currentData: ChatSettings): ChatSettings = currentData.copy( + visibleBadges = currentData.visibleBadges.plus(VisibleBadges.SharedChat).distinct(), + sharedChatMigration = 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), ) - 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 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) - - val restartChat = settings.distinctUntilChanged { old, new -> - old.showTimestamps != new.showTimestamps || + 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 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) + + 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 - } + } fun current() = currentSettings.value 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 461d7dcca..38f7e5590 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 @@ -54,11 +54,7 @@ import org.koin.compose.viewmodel.koinViewModel import kotlin.math.roundToInt @Composable -fun ChatSettingsScreen( - onNavToCommands: () -> Unit, - onNavToUserDisplays: () -> Unit, - onNavBack: () -> Unit, -) { +fun ChatSettingsScreen(onNavToCommands: () -> Unit, onNavToUserDisplays: () -> Unit, onNavBack: () -> Unit) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value val restartRequiredTitle = stringResource(R.string.restart_required) @@ -116,15 +112,15 @@ 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()) + .verticalScroll(rememberScrollState()), ) { GeneralCategory( suggestions = settings.suggestions, @@ -235,7 +231,7 @@ private fun GeneralCategory( values = UserLongClickBehavior.entries.toImmutableList(), entries = longClickEntries, selected = userLongClickBehavior, - onChange ={ onInteraction(ChatSettingsInteraction.UserLongClick(it)) }, + onChange = { onInteraction(ChatSettingsInteraction.UserLongClick(it)) }, ) PreferenceItem( @@ -262,7 +258,7 @@ private fun GeneralCategory( values = timestampFormats, entries = timestampFormats, selected = timestampFormat, - onChange ={ onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, + onChange = { onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, ) val entries = stringArrayResource(R.array.badges_entries) @@ -274,14 +270,14 @@ private fun GeneralCategory( initialSelected = visibleBadges, values = VisibleBadges.entries.toImmutableList(), entries = entries, - onChange ={ onInteraction(ChatSettingsInteraction.Badges(it)) }, + 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(), - onChange ={ onInteraction(ChatSettingsInteraction.Emotes(it)) }, + onChange = { onInteraction(ChatSettingsInteraction.Emotes(it)) }, ) } } @@ -321,18 +317,13 @@ private fun SevenTVCategory( values = LiveUpdatesBackgroundBehavior.entries.toImmutableList(), entries = liveUpdateEntries, selected = sevenTVLiveEmoteUpdatesBehavior, - onChange ={ onInteraction(ChatSettingsInteraction.LiveEmoteUpdatesBehavior(it)) }, + onChange = { onInteraction(ChatSettingsInteraction.LiveEmoteUpdatesBehavior(it)) }, ) } } @Composable -private fun MessageHistoryCategory( - loadMessageHistory: Boolean, - loadMessageHistoryAfterReconnect: Boolean, - messageHistoryDashboardUrl: String, - onInteraction: (ChatSettingsInteraction) -> Unit, -) { +private fun MessageHistoryCategory(loadMessageHistory: Boolean, loadMessageHistoryAfterReconnect: Boolean, messageHistoryDashboardUrl: String, onInteraction: (ChatSettingsInteraction) -> Unit) { val launcher = LocalUriHandler.current PreferenceCategory(title = stringResource(R.string.preference_message_history_header)) { SwitchPreferenceItem( @@ -355,10 +346,7 @@ private fun MessageHistoryCategory( } @Composable -private fun ChannelDataCategory( - showChatModes: Boolean, - onInteraction: (ChatSettingsInteraction) -> Unit, -) { +private fun ChannelDataCategory(showChatModes: Boolean, onInteraction: (ChatSettingsInteraction) -> Unit) { PreferenceCategory(title = stringResource(R.string.preference_channel_data_header)) { SwitchPreferenceItem( title = stringResource(R.string.preference_roomstate_title), 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 index 54db0a5a3..75a3d1b64 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt @@ -9,22 +9,39 @@ sealed interface ChatSettingsEvent { sealed interface ChatSettingsInteraction { data class Suggestions(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 } 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 ce5a2afb0..1297dfb8b 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 @@ -14,55 +14,100 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class ChatSettingsViewModel( - private val chatSettingsDataStore: ChatSettingsDataStore, -) : ViewModel() { - +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.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.Suggestions -> { + chatSettingsDataStore.update { it.copy(suggestions = 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 -> { 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) } + } } } } 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 98b095822..b343d0b16 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 @@ -73,11 +73,7 @@ fun CustomCommandsScreen(onNavBack: () -> Unit) { } @Composable -private fun CustomCommandsScreen( - initialCommands: ImmutableList, - onSaveAndNavBack: (List) -> Unit, - onSave: (List) -> Unit, -) { +private fun CustomCommandsScreen(initialCommands: ImmutableList, onSaveAndNavBack: (List) -> Unit, onSave: (List) -> Unit) { val focusManager = LocalFocusManager.current val commands = remember { initialCommands.toMutableStateList() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -108,7 +104,7 @@ private fun CustomCommandsScreen( onClick = { onSaveAndNavBack(commands) }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -125,7 +121,7 @@ private fun CustomCommandsScreen( 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) } } }, @@ -181,14 +177,7 @@ private fun CustomCommandsScreen( } @Composable -private fun CustomCommandItem( - trigger: String, - command: String, - onTriggerChange: (String) -> Unit, - onCommandChange: (String) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun CustomCommandItem(trigger: String, command: String, onTriggerChange: (String) -> Unit, onCommandChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { SwipeToDelete(onRemove, modifier) { ElevatedCard { Row { @@ -218,7 +207,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..9bcb1055a 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 @@ -15,13 +15,14 @@ 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(), - ) + val commands = + chatSettingsDataStore.settings + .map { it.customCommands.toImmutableList() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = chatSettingsDataStore.current().customCommands.toImmutableList(), + ) fun save(commands: List) = viewModelScope.launch { val filtered = commands.filter { it.trigger.isNotBlank() && it.command.isNotBlank() } 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 6b6a69cf2..09d6cacb7 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 @@ -7,6 +7,7 @@ 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 } 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 695451268..ab062b05b 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 @@ -126,11 +126,11 @@ private fun UserDisplayScreen( } } - 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) } } } @@ -161,7 +161,7 @@ private fun UserDisplayScreen( }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -208,12 +208,7 @@ private fun UserDisplayScreen( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun UserDisplayItem( - item: UserDisplayItem, - onChange: (UserDisplayItem) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun UserDisplayItem(item: UserDisplayItem, onChange: (UserDisplayItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { SwipeToDelete(onRemove, modifier) { ElevatedCard { Row { @@ -308,7 +303,7 @@ private fun UserDisplayItem( }, update = { it.setCurrentColor(selectedColor) - } + }, ) } } @@ -317,11 +312,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 34b83fa6e..f16863f44 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 @@ -13,10 +13,7 @@ import kotlinx.coroutines.withContext import org.koin.android.annotation.KoinViewModel @KoinViewModel -class UserDisplayViewModel( - private val userDisplayRepository: UserDisplayRepository -) : ViewModel() { - +class UserDisplayViewModel(private val userDisplayRepository: UserDisplayRepository) : ViewModel() { private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() 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 2eff03e62..2b7edfe16 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 @@ -18,13 +18,7 @@ import androidx.compose.ui.unit.dp @Suppress("LambdaParameterEventTrailing") @Composable -fun CheckboxWithText( - text: String, - checked: Boolean, - modifier: Modifier = Modifier, - enabled: Boolean = true, - onCheckedChange: (Boolean) -> Unit, -) { +fun CheckboxWithText(text: String, checked: Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit) { val interactionSource = remember { MutableInteractionSource() } Row( verticalAlignment = Alignment.CenterVertically, 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 eacd1a265..4b6024b24 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 @@ -18,10 +18,7 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.ui.theme.DankChatTheme @Composable -fun PreferenceCategory( - title: String, - content: @Composable ColumnScope.() -> Unit, -) { +fun PreferenceCategory(title: String, content: @Composable ColumnScope.() -> Unit) { Column( modifier = Modifier.padding(top = 16.dp), ) { @@ -33,10 +30,7 @@ fun PreferenceCategory( } @Composable -fun PreferenceCategoryWithSummary( - title: @Composable () -> Unit, - summary: @Composable () -> Unit, -) { +fun PreferenceCategoryWithSummary(title: @Composable () -> Unit, summary: @Composable () -> Unit) { Column( modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), ) { @@ -63,7 +57,7 @@ private fun PreferenceCategoryPreview(@PreviewParameter(provider = LoremIpsum::c Surface { PreferenceCategoryWithSummary( title = { PreferenceCategoryTitle("Title") }, - summary = { PreferenceSummary(loremIpsum.take(100)) } + summary = { PreferenceSummary(loremIpsum.take(100)) }, ) } } @@ -79,7 +73,7 @@ private fun PreferenceCategoryWithItemsPreview(@PreviewParameter(provider = Lore 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 68649dfb5..abcf08c1c 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 @@ -40,13 +40,7 @@ import com.flxrs.dankchat.utils.compose.ContentAlpha import kotlin.math.roundToInt @Composable -fun SwitchPreferenceItem( - title: String, - isChecked: Boolean, - onClick: (Boolean) -> Unit, - isEnabled: Boolean = true, - summary: String? = null, -) { +fun SwitchPreferenceItem(title: String, isChecked: Boolean, onClick: (Boolean) -> Unit, isEnabled: Boolean = true, summary: String? = null) { val interactionSource = remember { MutableInteractionSource() } HorizontalPreferenceItemWrapper( title = title, @@ -64,13 +58,7 @@ interface ExpandablePreferenceScope { } @Composable -fun ExpandablePreferenceItem( - title: String, - icon: ImageVector? = null, - isEnabled: Boolean = true, - summary: String? = null, - content: @Composable ExpandablePreferenceScope.() -> Unit, -) { +fun ExpandablePreferenceItem(title: String, icon: ImageVector? = null, isEnabled: Boolean = true, summary: String? = null, content: @Composable ExpandablePreferenceScope.() -> Unit) { var contentVisible by remember { mutableStateOf(false) } val scope = object : ExpandablePreferenceScope { override fun dismiss() { @@ -80,7 +68,7 @@ fun ExpandablePreferenceItem( val contentColor = LocalContentColor.current val color = when { isEnabled -> contentColor - else -> contentColor.copy(alpha = ContentAlpha.disabled) + else -> contentColor.copy(alpha = ContentAlpha.disabled) } HorizontalPreferenceItemWrapper( title = title, @@ -136,19 +124,12 @@ fun SliderPreferenceItem( ) } } - } + }, ) } @Composable -fun PreferenceItem( - title: String, - icon: ImageVector? = null, - trailingIcon: ImageVector? = null, - isEnabled: Boolean = true, - summary: String? = null, - onClick: () -> Unit = { }, -) { +fun PreferenceItem(title: String, icon: ImageVector? = null, trailingIcon: ImageVector? = null, isEnabled: Boolean = true, summary: String? = null, onClick: () -> Unit = { }) { HorizontalPreferenceItemWrapper( title = title, icon = icon, @@ -160,11 +141,11 @@ fun PreferenceItem( val contentColor = LocalContentColor.current val color = when { isEnabled -> contentColor - else -> contentColor.copy(alpha = ContentAlpha.disabled) + else -> contentColor.copy(alpha = ContentAlpha.disabled) } Icon(it, title, Modifier.padding(end = 4.dp), color) } - } + }, ) } @@ -201,7 +182,6 @@ private fun HorizontalPreferenceItemWrapper( content() } } - } } @@ -239,17 +219,11 @@ private fun VerticalPreferenceItemWrapper( } @Composable -private fun RowScope.PreferenceItemContent( - title: String, - isEnabled: Boolean, - icon: ImageVector?, - summary: String?, - textPaddingValues: PaddingValues = PaddingValues(vertical = 16.dp), -) { +private fun RowScope.PreferenceItemContent(title: String, isEnabled: Boolean, icon: ImageVector?, summary: String?, textPaddingValues: PaddingValues = PaddingValues(vertical = 16.dp)) { val contentColor = LocalContentColor.current val color = when { isEnabled -> contentColor - else -> contentColor.copy(alpha = ContentAlpha.disabled) + else -> contentColor.copy(alpha = ContentAlpha.disabled) } if (icon != null) { Icon( @@ -262,7 +236,7 @@ private fun RowScope.PreferenceItemContent( Column( Modifier .padding(textPaddingValues) - .weight(1f) + .weight(1f), ) { Text( text = title, 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..2185df08c 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 @@ -13,7 +13,7 @@ fun PreferenceSummary(summary: AnnotatedString, modifier: Modifier = Modifier, i val contentColor = LocalContentColor.current val color = when { isEnabled -> contentColor.copy(alpha = ContentAlpha.high) - else -> contentColor.copy(alpha = ContentAlpha.disabled) + else -> contentColor.copy(alpha = ContentAlpha.disabled) } Text( text = summary, @@ -28,7 +28,7 @@ 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) + else -> contentColor.copy(alpha = ContentAlpha.disabled) } Text( text = summary, 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..fc9f6abd5 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 @@ -12,12 +12,7 @@ import androidx.compose.ui.graphics.Color import kotlinx.coroutines.launch @Composable -fun PreferenceTabRow( - appBarContainerColor: State, - pagerState: PagerState, - tabCount: Int, - tabText: @Composable (Int) -> String, -) { +fun PreferenceTabRow(appBarContainerColor: State, pagerState: PagerState, tabCount: Int, tabText: @Composable (Int) -> String) { val scope = rememberCoroutineScope() PrimaryTabRow( containerColor = appBarContainerColor.value, 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 d8b018cd8..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 @@ -12,7 +12,6 @@ data class DeveloperSettings( 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..e9cbb4d18 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 @@ -18,42 +18,41 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class DeveloperSettingsDataStore( - context: Context, - dispatchersProvider: DispatchersProvider, -) { - +class DeveloperSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { 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/DeveloperSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt index c5092972e..b5fac12f8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -84,9 +84,7 @@ import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @Composable -fun DeveloperSettingsScreen( - onBack: () -> Unit, -) { +fun DeveloperSettingsScreen(onBack: () -> Unit) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value @@ -98,7 +96,7 @@ fun DeveloperSettingsScreen( LaunchedEffect(viewModel) { viewModel.events.collectLatest { when (it) { - DeveloperSettingsEvent.RestartRequired -> { + DeveloperSettingsEvent.RestartRequired -> { val result = snackbarHostState.showSnackbar( message = restartRequiredTitle, actionLabel = restartRequiredAction, @@ -126,12 +124,7 @@ fun DeveloperSettingsScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DeveloperSettingsContent( - settings: DeveloperSettings, - snackbarHostState: SnackbarHostState, - onInteraction: (DeveloperSettingsInteraction) -> Unit, - onBack: () -> Unit, -) { +private fun DeveloperSettingsContent(settings: DeveloperSettings, snackbarHostState: SnackbarHostState, onInteraction: (DeveloperSettingsInteraction) -> Unit, onBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), @@ -146,9 +139,9 @@ private fun DeveloperSettingsContent( onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) - } + }, ) { padding -> Column( modifier = Modifier @@ -194,7 +187,7 @@ private fun DeveloperSettingsContent( onClick = { enabled -> val protocol = when { enabled -> ChatSendProtocol.Helix - else -> ChatSendProtocol.IRC + else -> ChatSendProtocol.IRC } onInteraction(DeveloperSettingsInteraction.ChatSendProtocolChanged(protocol)) }, @@ -223,7 +216,7 @@ private fun DeveloperSettingsContent( onRequestRestart = { dismiss() onInteraction(DeveloperSettingsInteraction.RestartRequired) - } + }, ) } PreferenceItem( @@ -253,10 +246,7 @@ private fun DeveloperSettingsContent( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CustomRecentMessagesHostBottomSheet( - initialHost: String, - onInteraction: (DeveloperSettingsInteraction) -> Unit, -) { +private fun CustomRecentMessagesHostBottomSheet(initialHost: String, onInteraction: (DeveloperSettingsInteraction) -> Unit) { var host by remember(initialHost) { mutableStateOf(initialHost) } ModalBottomSheet( onDismissRequest = { onInteraction(DeveloperSettingsInteraction.CustomRecentMessagesHost(host)) }, @@ -297,10 +287,7 @@ private fun CustomRecentMessagesHostBottomSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CustomLoginBottomSheet( - onDismissRequest: () -> Unit, - onRequestRestart: () -> Unit, -) { +private fun CustomLoginBottomSheet(onDismissRequest: () -> Unit, onRequestRestart: () -> Unit) { val scope = rememberCoroutineScope() val customLoginViewModel = koinInject() val state = customLoginViewModel.customLoginState.collectAsStateWithLifecycle().value @@ -308,11 +295,11 @@ private fun CustomLoginBottomSheet( 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.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 + else -> null } LaunchedEffect(state) { @@ -332,7 +319,7 @@ private fun CustomLoginBottomSheet( style = MaterialTheme.typography.titleLarge, modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp) + .padding(bottom = 8.dp), ) Text( text = stringResource(R.string.custom_login_hint), @@ -366,7 +353,7 @@ private fun CustomLoginBottomSheet( state = token, textObfuscationMode = when { showPassword -> TextObfuscationMode.Visible - else -> TextObfuscationMode.Hidden + else -> TextObfuscationMode.Hidden }, label = { Text(stringResource(R.string.oauth_token)) }, isError = error != null, @@ -379,7 +366,7 @@ private fun CustomLoginBottomSheet( imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, contentDescription = null, ) - } + }, ) }, keyboardOptions = KeyboardOptions( @@ -447,7 +434,7 @@ private fun ShowScopesBottomSheet(scopes: String, onDismissRequest: () -> Unit) style = MaterialTheme.typography.titleLarge, modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp) + .padding(bottom = 8.dp), ) OutlinedTextField( value = scopes, @@ -460,9 +447,9 @@ private fun ShowScopesBottomSheet(scopes: String, onDismissRequest: () -> Unit) clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("scopes", scopes))) } }, - content = { Icon(imageVector = Icons.Default.ContentCopy, contentDescription = null) } + content = { Icon(imageVector = Icons.Default.ContentCopy, contentDescription = null) }, ) - } + }, ) } Spacer(Modifier.height(16.dp)) 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 index 410e8905c..d4d0db9c7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt @@ -2,19 +2,30 @@ 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 2941685e2..9391b06c2 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 @@ -24,13 +24,13 @@ class DeveloperSettingsViewModel( 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() @@ -38,29 +38,48 @@ 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.ChatSendProtocolChanged -> developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } - is DeveloperSettingsInteraction.RestartRequired -> _events.emit(DeveloperSettingsEvent.RestartRequired) - is DeveloperSettingsInteraction.ResetOnboarding -> { + is DeveloperSettingsInteraction.EventSubDebugOutput -> { + developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } + } + + is DeveloperSettingsInteraction.ChatSendProtocolChanged -> { + developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } + } + + is DeveloperSettingsInteraction.RestartRequired -> { + _events.emit(DeveloperSettingsEvent.RestartRequired) + } + + is DeveloperSettingsInteraction.ResetOnboarding -> { onboardingDataStore.update { it.copy( hasCompletedOnboarding = false, @@ -70,7 +89,7 @@ class DeveloperSettingsViewModel( _events.emit(DeveloperSettingsEvent.RestartRequired) } - is DeveloperSettingsInteraction.ResetTour -> { + is DeveloperSettingsInteraction.ResetTour -> { onboardingDataStore.update { it.copy( featureTourVersion = 0, @@ -82,7 +101,7 @@ class DeveloperSettingsViewModel( _events.emit(DeveloperSettingsEvent.RestartRequired) } - is DeveloperSettingsInteraction.RevokeToken -> { + is DeveloperSettingsInteraction.RevokeToken -> { val token = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return@launch val clientId = authDataStore.clientId authApiClient.revokeToken(token, clientId) 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..e3671d0d5 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,16 @@ 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 - ) : CustomLoginState + + data class MissingScopes(val missingScopes: String, val validation: ValidateDto, val token: String, val dialogOpen: Boolean) : 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 88aea3b8b..92497b740 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 @@ -19,11 +19,7 @@ import kotlinx.coroutines.flow.update import org.koin.core.annotation.Factory @Factory -class CustomLoginViewModel( - private val authApiClient: AuthApiClient, - private val authDataStore: AuthDataStore, -) { - +class CustomLoginViewModel(private val authApiClient: AuthApiClient, private val authDataStore: AuthDataStore) { private val _customLoginState = MutableStateFlow(Default) val customLoginState = _customLoginState.asStateFlow() @@ -36,30 +32,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 } } @@ -81,5 +80,6 @@ class CustomLoginViewModel( } fun getScopes() = AuthApiClient.SCOPES.joinToString(separator = "+") + fun getToken() = authDataStore.oAuthKey?.withoutOAuthPrefix.orEmpty() } 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..4e8b8a4a4 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 @@ -3,15 +3,11 @@ package com.flxrs.dankchat.preferences.notifications import kotlinx.serialization.Serializable @Serializable -data class NotificationsSettings( - val showNotifications: Boolean = true, - val showWhisperNotifications: Boolean = true, - val mentionFormat: MentionFormat = MentionFormat.Name, -) +data class NotificationsSettings(val showNotifications: Boolean = true, val showWhisperNotifications: Boolean = true, val mentionFormat: MentionFormat = MentionFormat.Name) 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 3f7b9c39c..19c57f212 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 @@ -19,48 +19,57 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class NotificationsSettingsDataStore( - context: Context, - dispatchersProvider: DispatchersProvider, -) { - +class NotificationsSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { 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/NotificationsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsScreen.kt index 39e7a56c0..664726f22 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 @@ -36,11 +36,7 @@ import kotlinx.collections.immutable.toImmutableList import org.koin.compose.viewmodel.koinViewModel @Composable -fun NotificationsSettingsScreen( - onNavToHighlights: () -> Unit, - onNavToIgnores: () -> Unit, - onNavBack: () -> Unit, -) { +fun NotificationsSettingsScreen(onNavToHighlights: () -> Unit, onNavToIgnores: () -> Unit, onNavBack: () -> Unit) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value NotificationsSettingsScreen( @@ -73,7 +69,7 @@ private fun NotificationsSettingsScreen( onClick = onNavBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, ) { padding -> @@ -101,11 +97,7 @@ private fun NotificationsSettingsScreen( } @Composable -fun NotificationsCategory( - showNotifications: Boolean, - showWhisperNotifications: Boolean, - onInteraction: (NotificationsSettingsInteraction) -> Unit, -) { +fun NotificationsCategory(showNotifications: Boolean, showWhisperNotifications: Boolean, onInteraction: (NotificationsSettingsInteraction) -> Unit) { PreferenceCategory(title = stringResource(R.string.preference_notification_header)) { SwitchPreferenceItem( title = stringResource(R.string.preference_notification_title), @@ -123,12 +115,7 @@ fun NotificationsCategory( } @Composable -fun MentionsCategory( - mentionFormat: MentionFormat, - onInteraction: (NotificationsSettingsInteraction) -> Unit, - onNavToHighlights: () -> Unit, - onNavToIgnores: () -> Unit, -) { +fun MentionsCategory(mentionFormat: MentionFormat, onInteraction: (NotificationsSettingsInteraction) -> Unit, onNavToHighlights: () -> Unit, onNavToIgnores: () -> Unit) { PreferenceCategory(title = stringResource(R.string.mentions)) { val entries = remember { MentionFormat.entries.map { it.template }.toImmutableList() } PreferenceListDialog( @@ -137,7 +124,7 @@ fun MentionsCategory( values = MentionFormat.entries.toImmutableList(), entries = entries, selected = mentionFormat, - onChange ={ 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..a229f4fed 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 @@ -10,23 +10,21 @@ import org.koin.android.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(), - ) +class NotificationsSettingsViewModel(private val notificationsSettingsDataStore: NotificationsSettingsDataStore) : ViewModel() { + 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) } } } } @@ -34,6 +32,8 @@ class NotificationsSettingsViewModel( sealed interface 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 c6049cd4f..04d72bb11 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 @@ -7,6 +7,7 @@ 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 } 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 38aefa3f2..5172404ce 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 @@ -30,7 +30,7 @@ data class MessageHighlightItem( FirstMessage, ElevatedMessage, Reply, - Custom + Custom, } val canNotify = type in WITH_NOTIFIES @@ -40,14 +40,8 @@ data class MessageHighlightItem( } } -data class UserHighlightItem( - override val id: Long, - val enabled: Boolean, - val username: String, - val createNotification: Boolean, - val notificationsEnabled: Boolean, - val customColor: Int?, -) : HighlightItem +data class UserHighlightItem(override val id: Long, val enabled: Boolean, val username: String, val createNotification: Boolean, val notificationsEnabled: Boolean, val customColor: Int?) : + HighlightItem data class BadgeHighlightItem( override val id: Long, @@ -59,12 +53,7 @@ data class BadgeHighlightItem( val notificationsEnabled: Boolean, ) : HighlightItem -data class BlacklistedUserItem( - override val id: Long, - val enabled: Boolean, - val username: String, - val isRegex: Boolean, -) : HighlightItem +data class BlacklistedUserItem(override val id: Long, val enabled: Boolean, val username: String, val isRegex: Boolean) : HighlightItem fun MessageHighlightEntity.toItem(loggedIn: Boolean, notificationsEnabled: Boolean) = MessageHighlightItem( id = id, @@ -91,25 +80,25 @@ 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.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.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 31a7522d2..f67e1e6b2 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 @@ -163,12 +163,12 @@ private fun HighlightsScreen( } } - 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) } } } @@ -199,7 +199,7 @@ private fun HighlightsScreen( }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -223,9 +223,9 @@ private fun HighlightsScreen( .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.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( @@ -242,12 +242,12 @@ private fun HighlightsScreen( 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) } - } + }, ) } @@ -259,7 +259,7 @@ private fun HighlightsScreen( ) { page -> val listState = listStates[page] when (HighlightsTab.entries[page]) { - HighlightsTab.Messages -> HighlightsList( + HighlightsTab.Messages -> HighlightsList( highlights = messageHighlights, listState = listState, ) { idx, item -> @@ -274,7 +274,7 @@ private fun HighlightsScreen( ) } - HighlightsTab.Users -> HighlightsList( + HighlightsTab.Users -> HighlightsList( highlights = userHighlights, listState = listState, ) { idx, item -> @@ -289,7 +289,7 @@ private fun HighlightsScreen( ) } - HighlightsTab.Badges -> HighlightsList( + HighlightsTab.Badges -> HighlightsList( highlights = badgeHighlights, listState = listState, ) { idx, item -> @@ -325,12 +325,7 @@ private fun HighlightsScreen( } @Composable -private fun HighlightsList( - highlights: SnapshotStateList, - listState: LazyListState, - itemContent: @Composable LazyItemScope.(Int, T) -> Unit, -) { - +private fun HighlightsList(highlights: SnapshotStateList, listState: LazyListState, itemContent: @Composable LazyItemScope.(Int, T) -> Unit) { DankBackground(visible = highlights.isEmpty()) LazyColumn( @@ -351,34 +346,29 @@ private fun HighlightsList( } @Composable -private fun MessageHighlightItem( - item: MessageHighlightItem, - onChange: (MessageHighlightItem) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun MessageHighlightItem(item: MessageHighlightItem, 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.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 + 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) + .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( @@ -447,11 +437,11 @@ private fun MessageHighlightItem( } 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) + 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) } HighlightColorPicker( color = item.customColor ?: defaultColor, @@ -463,12 +453,7 @@ private fun MessageHighlightItem( } @Composable -private fun UserHighlightItem( - item: UserHighlightItem, - onChange: (UserHighlightItem) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun UserHighlightItem(item: UserHighlightItem, onChange: (UserHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { ElevatedCard(modifier) { Row { Column( @@ -518,12 +503,7 @@ private fun UserHighlightItem( } @Composable -private fun BadgeHighlightItem( - item: BadgeHighlightItem, - onChange: (BadgeHighlightItem) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun BadgeHighlightItem(item: BadgeHighlightItem, onChange: (BadgeHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { ElevatedCard(modifier) { Row { Column( @@ -543,25 +523,25 @@ 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) + "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) + "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) + .padding(top = 8.dp), ) { Text( text = name, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier.align(Alignment.Center), ) } } @@ -601,12 +581,7 @@ private fun BadgeHighlightItem( } @Composable -private fun BlacklistedUserItem( - item: BlacklistedUserItem, - onChange: (BlacklistedUserItem) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun BlacklistedUserItem(item: BlacklistedUserItem, onChange: (BlacklistedUserItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { val launcher = LocalUriHandler.current ElevatedCard(modifier) { Row { @@ -655,12 +630,7 @@ private fun BlacklistedUserItem( } @Composable -private fun HighlightColorPicker( - color: Int, - defaultColor: Int, - enabled: Boolean, - onColorSelect: (Int) -> Unit, -) { +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( @@ -671,12 +641,12 @@ private fun HighlightColorPicker( Spacer( Modifier .size(ButtonDefaults.IconSize) - .background(color = Color(color), shape = CircleShape) + .background(color = Color(color), shape = CircleShape), ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text(text = stringResource(R.string.choose_highlight_color)) }, - modifier = Modifier.padding(12.dp) + modifier = Modifier.padding(12.dp), ) if (showColorPicker) { ModalBottomSheet( @@ -697,7 +667,7 @@ private fun HighlightColorPicker( ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { TextButton( onClick = { selectedColor = defaultColor }, @@ -721,7 +691,7 @@ private fun HighlightColorPicker( }, 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 8ffe781b2..726f70e83 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 @@ -24,7 +24,6 @@ class HighlightsViewModel( private val preferenceStore: DankChatPreferenceStore, private val notificationsSettingsDataStore: NotificationsSettingsDataStore, ) : ViewModel() { - private val _currentTab = MutableStateFlow(HighlightsTab.Messages) private val eventChannel = Channel(Channel.BUFFERED) @@ -59,19 +58,19 @@ 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) position = userHighlights.lastIndex } - HighlightsTab.Badges -> { + HighlightsTab.Badges -> { val entity = highlightsRepository.addBadgeHighlight() badgeHighlights += entity.toItem(notificationsEnabled) position = badgeHighlights.lastIndex @@ -95,19 +94,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 +124,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) 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 4014324d1..551b5c8eb 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 @@ -7,8 +7,11 @@ 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 } 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..011a4889b 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 @@ -27,23 +27,13 @@ data class MessageIgnoreItem( ChannelPointRedemption, FirstMessage, ElevatedMessage, - Custom + Custom, } } -data class UserIgnoreItem( - override val id: Long, - val enabled: Boolean, - val username: String, - val isRegex: Boolean, - val isCaseSensitive: Boolean -) : IgnoreItem +data class UserIgnoreItem(override val id: Long, val enabled: Boolean, val username: String, val isRegex: Boolean, val isCaseSensitive: Boolean) : IgnoreItem -data class TwitchBlockItem( - override val id: Long, - val username: UserName, - val userId: UserId, -) : IgnoreItem +data class TwitchBlockItem(override val id: Long, val username: UserName, val userId: UserId) : IgnoreItem fun MessageIgnoreEntity.toItem() = MessageIgnoreItem( id = id, @@ -53,7 +43,7 @@ fun MessageIgnoreEntity.toItem() = MessageIgnoreItem( isRegex = isRegex, isCaseSensitive = isCaseSensitive, isBlockMessage = isBlockMessage, - replacement = replacement ?: "" + replacement = replacement ?: "", ) fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( @@ -64,28 +54,29 @@ fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( isRegex = isRegex, isCaseSensitive = isCaseSensitive, isBlockMessage = isBlockMessage, - replacement = when { + replacement = + when { isBlockMessage -> null - else -> replacement - } + 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.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.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 +92,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 5650fe789..1bbd13d9a 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 @@ -144,10 +144,10 @@ private fun IgnoresScreen( .collectLatest { event -> focusManager.clearFocus() when (event) { - is IgnoreEvent.ItemRemoved -> { + is IgnoreEvent.ItemRemoved -> { val message = when (event.item) { is TwitchBlockItem -> resources.getString(R.string.unblocked_user, event.item.username) - else -> itemRemovedMsg + else -> itemRemovedMsg } val result = snackbarHost.showSnackbar( @@ -160,16 +160,16 @@ private fun IgnoresScreen( } } - 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 -> { + is IgnoreEvent.BlockError -> { val message = resources.getString(R.string.blocked_user_failed, event.item.username) snackbarHost.showSnackbar(message) } @@ -206,7 +206,7 @@ private fun IgnoresScreen( }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -237,8 +237,8 @@ private fun IgnoresScreen( ) { 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) + IgnoresTab.Users -> stringResource(R.string.ignores_users_title) + IgnoresTab.Twitch -> stringResource(R.string.ignores_twitch_title) } Text( text = subtitle, @@ -255,10 +255,10 @@ private fun IgnoresScreen( 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) } - } + }, ) } @@ -286,7 +286,7 @@ private fun IgnoresScreen( ) } - IgnoresTab.Users -> IgnoresList( + IgnoresTab.Users -> IgnoresList( tab = tab, ignores = userIgnores, listState = listState, @@ -302,7 +302,7 @@ private fun IgnoresScreen( ) } - IgnoresTab.Twitch -> IgnoresList( + IgnoresTab.Twitch -> IgnoresList( tab = tab, ignores = twitchBlocks, listState = listState, @@ -323,13 +323,7 @@ private fun IgnoresScreen( } @Composable -private fun IgnoresList( - tab: IgnoresTab, - ignores: SnapshotStateList, - listState: LazyListState, - itemContent: @Composable LazyItemScope.(Int, T) -> Unit, -) { - +private fun IgnoresList(tab: IgnoresTab, ignores: SnapshotStateList, listState: LazyListState, itemContent: @Composable LazyItemScope.(Int, T) -> Unit) { DankBackground(visible = ignores.isEmpty()) LazyColumn( @@ -346,7 +340,7 @@ private fun IgnoresList( item(key = "bottom-spacer") { val height = when (tab) { IgnoresTab.Messages, IgnoresTab.Users -> 112.dp - IgnoresTab.Twitch -> Dp.Unspecified + IgnoresTab.Twitch -> Dp.Unspecified } NavigationBarSpacer(Modifier.height(height)) } @@ -354,32 +348,27 @@ private fun IgnoresList( } @Composable -private fun MessageIgnoreItem( - item: MessageIgnoreItem, - onChange: (MessageIgnoreItem) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun MessageIgnoreItem(item: MessageIgnoreItem, 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.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 + 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 isCustom = item.type == MessageIgnoreItem.Type.Custom ElevatedCard(modifier) { Box( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp) + .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( @@ -460,19 +449,14 @@ private fun MessageIgnoreItem( } @Composable -private fun UserIgnoreItem( - item: UserIgnoreItem, - onChange: (UserIgnoreItem) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun UserIgnoreItem(item: UserIgnoreItem, onChange: (UserIgnoreItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { val launcher = LocalUriHandler.current ElevatedCard(modifier) { Row { Column( modifier = Modifier .weight(1f) - .padding(8.dp) + .padding(8.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), @@ -525,11 +509,7 @@ private fun UserIgnoreItem( } @Composable -private fun TwitchBlockItem( - item: TwitchBlockItem, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun TwitchBlockItem(item: TwitchBlockItem, onRemove: () -> Unit, modifier: Modifier = Modifier) { ElevatedCard(modifier) { Row { val colors = OutlinedTextFieldDefaults.colors() 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..bf55f514d 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 @@ -16,10 +16,7 @@ import kotlinx.coroutines.withContext import org.koin.android.annotation.KoinViewModel @KoinViewModel -class IgnoresViewModel( - private val ignoresRepository: IgnoresRepository -) : ViewModel() { - +class IgnoresViewModel(private val ignoresRepository: IgnoresRepository) : ViewModel() { private val _currentTab = MutableStateFlow(IgnoresTab.Messages) private val eventChannel = Channel(Channel.BUFFERED) @@ -53,13 +50,15 @@ 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)) } @@ -73,13 +72,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 +101,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 @@ -123,10 +121,7 @@ class IgnoresViewModel( sendEvent(IgnoreEvent.ItemRemoved(item, position)) } - fun updateIgnores( - messageIgnoreItems: List, - userIgnoreItems: List, - ) = viewModelScope.launch { + fun updateIgnores(messageIgnoreItems: List, userIgnoreItems: List) = viewModelScope.launch { filterMessageIgnores(messageIgnoreItems).let { (blankEntities, entities) -> ignoresRepository.updateMessageIgnores(entities) blankEntities.forEach { ignoresRepository.removeMessageIgnore(it) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt index e33858bd9..54582299d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -62,13 +62,7 @@ sealed interface SettingsNavigation { } @Composable -fun OverviewSettingsScreen( - isLoggedIn: Boolean, - hasChangelog: Boolean, - onBack: () -> Unit, - onLogout: () -> Unit, - onNavigate: (SettingsNavigation) -> Unit, -) { +fun OverviewSettingsScreen(isLoggedIn: Boolean, hasChangelog: Boolean, onBack: () -> Unit, onLogout: () -> Unit, onNavigate: (SettingsNavigation) -> Unit) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), @@ -84,7 +78,7 @@ fun OverviewSettingsScreen( onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }, ) - } + }, ) }, ) { padding -> 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 4fe2f6b38..4f6cfed8d 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 @@ -59,7 +59,7 @@ fun SecretDankerModeTrigger(content: @Composable SecretDankerScope.() -> Unit) { .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..6740f9bfe 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 @@ -19,11 +19,7 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class StreamsSettingsDataStore( - context: Context, - dispatchersProvider: DispatchersProvider, -) { - +class StreamsSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { private enum class StreamsPreferenceKeys(override val id: Int) : PreferenceKeys { FetchStreams(R.string.preference_fetch_streams_key), ShowStreamInfo(R.string.preference_streaminfo_key), @@ -31,40 +27,46 @@ class StreamsSettingsDataStore( 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/StreamsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt index b25eb1580..55f088deb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt @@ -32,25 +32,19 @@ import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem import org.koin.compose.viewmodel.koinViewModel @Composable -fun StreamsSettingsScreen( - onBack: () -> Unit, -) { +fun StreamsSettingsScreen(onBack: () -> Unit) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value StreamsSettingsContent( settings = settings, onInteraction = { viewModel.onInteraction(it) }, - onBack = onBack + onBack = onBack, ) } @Composable -private fun StreamsSettingsContent( - settings: StreamsSettings, - onInteraction: (StreamsSettingsInteraction) -> Unit, - onBack: () -> Unit, -) { +private fun StreamsSettingsContent(settings: StreamsSettings, onInteraction: (StreamsSettingsInteraction) -> Unit, onBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), @@ -64,9 +58,9 @@ private fun StreamsSettingsContent( onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) - } + }, ) { padding -> Column( modifier = Modifier @@ -105,7 +99,7 @@ private fun StreamsSettingsContent( val activity = LocalActivity.current val pipAvailable = remember { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - activity != null && activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + activity != null && activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } if (pipAvailable) { SwitchPreferenceItem( 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..66910bbf2 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 @@ -10,24 +10,22 @@ import org.koin.android.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(), - ) +class StreamsSettingsViewModel(private val dataStore: StreamsSettingsDataStore) : ViewModel() { + 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) } } } } @@ -35,8 +33,12 @@ class StreamsSettingsViewModel( 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 } 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..a436f50d5 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 @@ -15,42 +15,37 @@ data class ToolsSettings( val ttsIgnoreEmotes: Boolean = false, val ttsUserIgnoreList: Set = emptySet(), ) { - @Transient val ttsUserNameIgnores = ttsUserIgnoreList.toUserNames() } @Serializable -data class ImageUploaderConfig( - val uploadUrl: String, - val formField: String, - val headers: String?, - val imageLinkPattern: String?, - val deletionLinkPattern: String?, -) { - +data class ImageUploaderConfig(val uploadUrl: String, val formField: String, val headers: String?, val imageLinkPattern: String?, val deletionLinkPattern: String?) { @Transient - 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() + 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() 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..2add1107e 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 @@ -24,11 +24,7 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class ToolsSettingsDataStore( - context: Context, - dispatchersProvider: DispatchersProvider, -) { - +class ToolsSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { private enum class ToolsPreferenceKeys(override val id: Int) : PreferenceKeys { TTS(R.string.preference_tts_key), TTSQueue(R.string.preference_tts_queue_key), @@ -47,82 +43,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/ToolsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsScreen.kt index 72910f90f..dd5a1441f 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 @@ -83,11 +83,7 @@ import org.koin.compose.viewmodel.koinViewModel import sh.calvin.autolinktext.rememberAutoLinkText @Composable -fun ToolsSettingsScreen( - onNavToImageUploader: () -> Unit, - onNavToTTSUserIgnoreList: () -> Unit, - onNavBack: () -> Unit, -) { +fun ToolsSettingsScreen(onNavToImageUploader: () -> Unit, onNavToTTSUserIgnoreList: () -> Unit, onNavBack: () -> Unit) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value @@ -121,7 +117,7 @@ private fun ToolsSettingsScreen( onClick = onNavBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, ) { padding -> @@ -129,7 +125,7 @@ private fun ToolsSettingsScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .verticalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), ) { ImageUploaderCategory(hasRecentUploads = settings.hasRecentUploads, onNavToImageUploader = onNavToImageUploader) HorizontalDivider(thickness = Dp.Hairline) @@ -140,10 +136,7 @@ private fun ToolsSettingsScreen( } @Composable -fun ImageUploaderCategory( - hasRecentUploads: Boolean, - onNavToImageUploader: () -> Unit, -) { +fun ImageUploaderCategory(hasRecentUploads: Boolean, onNavToImageUploader: () -> Unit) { var recentUploadSheetOpen by remember { mutableStateOf(false) } PreferenceCategory(title = stringResource(R.string.preference_uploader_header)) { PreferenceItem( @@ -178,7 +171,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 -> @@ -276,17 +269,13 @@ fun RecentUploadItem(upload: RecentUpload) { } @Composable -fun TextToSpeechCategory( - settings: ToolsSettingsState, - onInteraction: (ToolsSettingsInteraction) -> Unit, - onNavToTTSUserIgnoreList: () -> Unit, -) { +fun TextToSpeechCategory(settings: ToolsSettingsState, onInteraction: (ToolsSettingsInteraction) -> Unit, onNavToTTSUserIgnoreList: () -> Unit) { 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)) + else -> onInteraction(ToolsSettingsInteraction.TTSEnabled(true)) } } SwitchPreferenceItem( @@ -311,7 +300,7 @@ fun TextToSpeechCategory( entries = modeEntries, selected = settings.ttsPlayMode, isEnabled = settings.ttsEnabled, - onChange ={ onInteraction(ToolsSettingsInteraction.TTSMode(it)) }, + onChange = { onInteraction(ToolsSettingsInteraction.TTSMode(it)) }, ) val formatMessage = stringResource(R.string.preference_tts_message_format_message) @@ -324,7 +313,7 @@ fun TextToSpeechCategory( entries = formatEntries, selected = settings.ttsMessageFormat, isEnabled = settings.ttsEnabled, - onChange ={ onInteraction(ToolsSettingsInteraction.TTSFormat(it)) }, + onChange = { onInteraction(ToolsSettingsInteraction.TTSFormat(it)) }, ) SwitchPreferenceItem( 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 index 4bf611f80..2f41b8e4a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt @@ -4,11 +4,17 @@ 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 TTSUserIgnoreList(val value: Set) : ToolsSettingsInteraction } 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 c6ecc5d89..7c6ac8f81 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 @@ -13,31 +13,28 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -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), - ) +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), + ) 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.TTSUserIgnoreList -> toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = interaction.value) } } } 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 b0346e9ba..6784924b4 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 @@ -69,11 +69,7 @@ fun TTSUserIgnoreListScreen(onNavBack: () -> Unit) { } @Composable -private fun UserIgnoreListScreen( - initialIgnores: ImmutableList, - onSaveAndNavBack: (List) -> Unit, - onSave: (List) -> Unit, -) { +private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSaveAndNavBack: (List) -> Unit, onSave: (List) -> Unit) { val focusManager = LocalFocusManager.current val ignores = remember { initialIgnores.toMutableStateList() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -121,10 +117,10 @@ private fun UserIgnoreListScreen( 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) } } - } + }, ) } }, @@ -136,7 +132,7 @@ private fun UserIgnoreListScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { itemsIndexed(ignores, key = { _, item -> item.id }) { idx, ignore -> UserIgnoreItem( @@ -150,7 +146,7 @@ private fun UserIgnoreListScreen( val result = snackbarHost.showSnackbar( message = itemRemovedMsg, actionLabel = undoMsg, - duration = SnackbarDuration.Short + duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) { focusManager.clearFocus() @@ -175,12 +171,7 @@ private fun UserIgnoreListScreen( } @Composable -private fun UserIgnoreItem( - user: String, - onUserChange: (String) -> Unit, - onRemove: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun UserIgnoreItem(user: String, onUserChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { SwipeToDelete(onRemove, modifier) { ElevatedCard { Row { 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..71f0a2aaa 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 @@ -14,17 +14,15 @@ import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @KoinViewModel -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() - ) +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(), + ) fun save(ignores: List) = viewModelScope.launch { val filtered = ignores.mapNotNullTo(mutableSetOf()) { it.user.takeIf { it.isNotBlank() } } 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 43d1b310f..00d492332 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 @@ -71,12 +71,7 @@ fun ImageUploaderScreen(onNavBack: () -> Unit) { } @Composable -private fun ImageUploaderScreen( - uploaderConfig: ImageUploaderConfig, - onReset: () -> Unit, - onSave: (ImageUploaderConfig) -> Unit, - onSaveAndNavBack: (ImageUploaderConfig) -> Unit -) { +private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () -> Unit, onSave: (ImageUploaderConfig) -> Unit, onSaveAndNavBack: (ImageUploaderConfig) -> Unit) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val uploadUrl = rememberTextFieldState(uploaderConfig.uploadUrl) val formField = rememberTextFieldState(uploaderConfig.formField) @@ -86,10 +81,10 @@ private fun ImageUploaderScreen( 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 + uploaderConfig.formField != formField.text || + uploaderConfig.headers.orEmpty() != headers.text || + uploaderConfig.imageLinkPattern.orEmpty() != linkPattern.text || + uploaderConfig.deletionLinkPattern.orEmpty() != deleteLinkPattern.text } } 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..bc359a167 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 @@ -12,23 +12,22 @@ import org.koin.android.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, - ) +class ImageUploaderViewModel(private val toolsSettingsDataStore: ToolsSettingsDataStore) : ViewModel() { + 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/RecentUploadsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUploadsViewModel.kt index f8b6e4e68..d10bfaa89 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 @@ -17,36 +17,34 @@ import java.util.Locale import kotlin.time.Duration.Companion.seconds @KoinViewModel -class RecentUploadsViewModel( - 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(), - ) +class RecentUploadsViewModel(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(), + ) 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/ui/changelog/ChangelogScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt index 9a62d81c1..2d224c827 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt @@ -28,9 +28,7 @@ import com.flxrs.dankchat.R import org.koin.compose.viewmodel.koinViewModel @Composable -fun ChangelogScreen( - onBack: () -> Unit, -) { +fun ChangelogScreen(onBack: () -> Unit) { val viewModel: ChangelogSheetViewModel = koinViewModel() val state = viewModel.state ?: return @@ -46,7 +44,7 @@ fun ChangelogScreen( Text(stringResource(R.string.preference_whats_new_header)) Text( text = stringResource(R.string.changelog_sheet_subtitle, state.version), - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelMedium, ) } }, @@ -55,21 +53,21 @@ fun ChangelogScreen( 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) + .padding(padding), ) { items(entries) { entry -> Text( text = entry, modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) HorizontalDivider() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt index 097807a48..c8297eb00 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt @@ -5,15 +5,13 @@ import com.flxrs.dankchat.preferences.DankChatPreferenceStore import org.koin.android.annotation.KoinViewModel @KoinViewModel -class ChangelogSheetViewModel( - dankChatPreferenceStore: DankChatPreferenceStore, -) : ViewModel() { - +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/DankChatVersion.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt index dd1402c3e..e2071d31e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt @@ -3,24 +3,23 @@ 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 = fromString(BuildConfig.VERSION_NAME)!! - private val COMPARATOR = Comparator - .comparingInt(DankChatVersion::major) - .thenComparingInt(DankChatVersion::minor) - .thenComparingInt(DankChatVersion::patch) + 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) } - } + 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/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt index 800f2a8e3..6c1702334 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt @@ -19,8 +19,8 @@ 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 + backgroundColor.alpha < 1f -> backgroundColor.compositeOver(surfaceColor) + else -> backgroundColor } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt index 530d66a60..bcbac195d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt @@ -20,8 +20,8 @@ fun rememberBackgroundColor(lightColor: Color, darkColor: Color): Color { return remember(raw, background) { when { raw == Color.Transparent -> Color.Transparent - raw.alpha < 1f -> raw.compositeOver(background) - else -> raw + raw.alpha < 1f -> raw.compositeOver(background) + else -> raw } } } 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 index d8e1b551f..eaa7f903e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -19,7 +19,7 @@ import org.koin.core.parameter.parametersOf /** * Standalone composable for chat display. * Extracted from ChatFragment to enable pure Compose integration. - * + * * This composable: * - Creates its own ChatViewModel scoped to the channel * - Collects messages from ViewModel @@ -52,7 +52,7 @@ fun ChatComposable( // Create ChatViewModel with channel-specific key for proper scoping val viewModel: ChatViewModel = koinViewModel( key = channel.value, - parameters = { parametersOf(channel) } + parameters = { parametersOf(channel) }, ) val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) 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 index 909d6a4c9..0e1a0a2ac 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -38,142 +38,230 @@ import org.koin.core.annotation.Single * 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 - } +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 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 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 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 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 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 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 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 - ) + is WhisperMessage -> { + msg.toWhisperMessageUi( + tag = item.tag, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha, + currentUserName = preferenceStore.userName, + ) + } } } - private fun SystemMessage.toSystemMessageUi( - tag: Int, - chatSettings: ChatSettings, - isAlternateBackground: Boolean, - textAlpha: Float, - ): ChatMessageUiState.SystemMessageUi { + 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.NoHistoryLoaded -> TextResource.Res(R.string.system_message_no_history) - 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 -> TextResource.Plain(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 ?: "")) - 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)) + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" } - 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) - ) + val message = + when (type) { + is SystemMessageType.Disconnected -> { + TextResource.Res(R.string.system_message_disconnected) + } + + is SystemMessageType.NoHistoryLoaded -> { + TextResource.Res(R.string.system_message_no_history) + } + + 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 -> { + TextResource.Plain(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 ?: "")) + } + + 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.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)) + 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, @@ -182,20 +270,18 @@ class ChatMessageMapper( lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, - message = message + message = message, ) } - private fun NoticeMessage.toNoticeMessageUi( - tag: Int, - chatSettings: ChatSettings, - isAlternateBackground: Boolean, - textAlpha: Float, - ): ChatMessageUiState.NoticeMessageUi { + 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 "" + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } return ChatMessageUiState.NoticeMessageUi( id = id, @@ -204,33 +290,34 @@ class ChatMessageMapper( lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, - message = message + message = message, ) } - private fun UserNoticeMessage.toUserNoticeMessageUi( - tag: Int, - chatSettings: ChatSettings, - isAlternateBackground: Boolean, - textAlpha: Float, - ): ChatMessageUiState.UserNoticeMessageUi { - val shouldHighlight = highlights.any { - it.type == HighlightType.Subscription || + private fun UserNoticeMessage.toUserNoticeMessageUi(tag: Int, chatSettings: ChatSettings, isAlternateBackground: Boolean, textAlpha: Float): ChatMessageUiState.UserNoticeMessageUi { + val shouldHighlight = + highlights.any { + it.type == HighlightType.Subscription || it.type == HighlightType.Announcement - } - val backgroundColors = when { - shouldHighlight -> getHighlightColors(HighlightType.Subscription) - else -> calculateCheckeredBackgroundColors(isAlternateBackground, false) - } - val timestamp = if (chatSettings.showTimestamps) { - DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) - } else "" + } + val backgroundColors = + when { + shouldHighlight -> getHighlightColors(HighlightType.Subscription) + 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 rawNameColor = tags["color"]?.ifBlank { null }?.let(android.graphics.Color::parseColor) - ?: login?.let { usersRepository.getCachedUserColor(it) } - ?: Message.DEFAULT_COLOR + val rawNameColor = + tags["color"]?.ifBlank { null }?.let(android.graphics.Color::parseColor) + ?: login?.let { usersRepository.getCachedUserColor(it) } + ?: Message.DEFAULT_COLOR return ChatMessageUiState.UserNoticeMessageUi( id = id, @@ -243,7 +330,7 @@ class ChatMessageMapper( message = message, displayName = displayName, rawNameColor = rawNameColor, - shouldHighlight = shouldHighlight + shouldHighlight = shouldHighlight, ) } @@ -255,15 +342,19 @@ class ChatMessageMapper( textAlpha: Float, ): ChatMessageUiState.ModerationMessageUi { val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) - val timestamp = if (chatSettings.showTimestamps) { - DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) - } else "" + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } - val arguments = buildList { - duration?.let(::add) - reason?.takeIf { it.isNotBlank() }?.let(::add) - sourceBroadcasterDisplay?.toString()?.let(::add) - }.toImmutableList() + val arguments = + buildList { + duration?.let(::add) + reason?.takeIf { it.isNotBlank() }?.let(::add) + sourceBroadcasterDisplay?.toString()?.let(::add) + }.toImmutableList() return ChatMessageUiState.ModerationMessageUi( id = id, @@ -281,21 +372,21 @@ class ChatMessageMapper( ) } - 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 - } + 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, @@ -306,17 +397,20 @@ class ChatMessageMapper( 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(), + 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, messageText = messageText?.takeIf { it.isNotEmpty() }, @@ -334,84 +428,106 @@ class ChatMessageMapper( 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 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 timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } - val nameText = when { - !chatSettings.showUsernames -> "" - isAction -> "$aliasOrFormattedName " - aliasOrFormattedName.isBlank() -> "" - else -> "$aliasOrFormattedName: " - } + 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 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) { + thread.toThreadUi() + } else { + null } - 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) { - thread.toThreadUi() - } else null - - val highlightHeader = highlights.highestPriorityHighlight()?.let { - when (it.type) { - HighlightType.FirstMessage -> TextResource.Res(R.string.highlight_header_first_time_chat) - HighlightType.ElevatedMessage -> TextResource.Res(R.string.highlight_header_elevated_chat) - else -> null + val highlightHeader = + highlights.highestPriorityHighlight()?.let { + when (it.type) { + HighlightType.FirstMessage -> TextResource.Res(R.string.highlight_header_first_time_chat) + HighlightType.ElevatedMessage -> TextResource.Res(R.string.highlight_header_elevated_chat) + else -> null + } } - } - val fullMessage = buildString { - if (isMentionTab && highlights.any { it.isMention }) { - append("#$channel ") - } - if (timestamp.isNotEmpty()) { - append("$timestamp ") + val fullMessage = + buildString { + if (isMentionTab && highlights.any { it.isMention }) { + append("#$channel ") + } + if (timestamp.isNotEmpty()) { + append("$timestamp ") + } + append(nameText) + append(message) } - append(nameText) - append(message) - } // Store raw color for normalization at render time (needs Compose theme context) val rawNameColor = userDisplay?.color ?: color @@ -437,19 +553,18 @@ class ChatMessageMapper( isAction = isAction, thread = threadUi, highlightHeader = highlightHeader, - fullMessage = fullMessage + fullMessage = fullMessage, ) } - private fun PointRedemptionMessage.toPointRedemptionMessageUi( - tag: Int, - chatSettings: ChatSettings, - textAlpha: Float, - ): ChatMessageUiState.PointRedemptionMessageUi { + 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 timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } val nameText = if (!requiresUserInput) aliasOrFormattedName else null @@ -464,7 +579,7 @@ class ChatMessageMapper( title = title, cost = cost, rewardImageUrl = rewardImageUrl, - requiresUserInput = requiresUserInput + requiresUserInput = requiresUserInput, ) } @@ -476,58 +591,71 @@ class ChatMessageMapper( currentUserName: UserName?, ): ChatMessageUiState.WhisperMessageUi { val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, true) - val timestamp = if (chatSettings.showTimestamps) { - DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) - } else "" + 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 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 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) - } - // Store raw colors for normalization at render time (needs Compose theme context) val rawSenderColor = userDisplay?.color ?: color val rawRecipientColor = recipientDisplay?.color ?: recipientColor @@ -551,51 +679,56 @@ class ChatMessageMapper( message = message, emotes = emoteUis, fullMessage = fullMessage, - replyTargetName = if (currentUserName != null && name.value.equals(currentUserName.value, ignoreCase = true)) recipientName else name + replyTargetName = if (currentUserName != null && name.value.equals(currentUserName.value, ignoreCase = true)) recipientName else name, ) } data class BackgroundColors(val light: Color, val dark: Color) - private fun calculateCheckeredBackgroundColors( - isAlternateBackground: Boolean, - enableCheckered: Boolean, - ): BackgroundColors { - return if (enableCheckered && isAlternateBackground) { - BackgroundColors(CHECKERED_LIGHT, CHECKERED_DARK) - } else { - BackgroundColors(Color.Transparent, Color.Transparent) - } + 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 { - return when (type) { - HighlightType.Subscription, - HighlightType.Announcement -> BackgroundColors( + private fun getHighlightColors(type: HighlightType): BackgroundColors = when (type) { + HighlightType.Subscription, + HighlightType.Announcement, + -> { + BackgroundColors( light = COLOR_SUB_HIGHLIGHT_LIGHT, dark = COLOR_SUB_HIGHLIGHT_DARK, ) + } - HighlightType.ChannelPointRedemption -> BackgroundColors( + HighlightType.ChannelPointRedemption -> { + BackgroundColors( light = COLOR_REDEMPTION_HIGHLIGHT_LIGHT, dark = COLOR_REDEMPTION_HIGHLIGHT_DARK, ) + } - HighlightType.ElevatedMessage -> BackgroundColors( + HighlightType.ElevatedMessage -> { + BackgroundColors( light = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT, dark = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK, ) + } - HighlightType.FirstMessage -> BackgroundColors( + 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( + HighlightType.Username, + HighlightType.Custom, + HighlightType.Reply, + HighlightType.Badge, + HighlightType.Notification, + -> { + BackgroundColors( light = COLOR_MENTION_HIGHLIGHT_LIGHT, dark = COLOR_MENTION_HIGHLIGHT_DARK, ) @@ -603,8 +736,9 @@ class ChatMessageMapper( } private fun Set.toBackgroundColors(): BackgroundColors { - val highlight = this.maxByOrNull { it.type.priority.value } - ?: return BackgroundColors(Color.Transparent, Color.Transparent) + val highlight = + this.maxByOrNull { it.type.priority.value } + ?: return BackgroundColors(Color.Transparent, Color.Transparent) if (highlight.customColor != null) { val color = Color(highlight.customColor) @@ -630,17 +764,23 @@ class ChatMessageMapper( private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK = Color(0xFF574500) // Checkered background colors - private val CHECKERED_LIGHT = Color( - android.graphics.Color.argb( - (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), - 0, 0, 0 + private val CHECKERED_LIGHT = + Color( + android.graphics.Color.argb( + (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), + 0, + 0, + 0, + ), ) - ) - private val CHECKERED_DARK = Color( - android.graphics.Color.argb( - (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), - 255, 255, 255 + private val CHECKERED_DARK = + Color( + android.graphics.Color.argb( + (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), + 255, + 255, + 255, + ), ) - ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt index 9b5d72b47..0677cf2c9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.unit.em * - Username colors * - Emotes and badges (via InlineTextContent) * - Clickable spans (usernames, links, emotes) - * + * * NOTE: fontSize should come from appearanceSettings.fontSize, not be hardcoded * NOTE: nameColor should come from the message's nameColor, not be hardcoded */ @@ -54,7 +54,7 @@ fun ChatMessageText( fontSize = fontSize * 0.95f, color = timestampColor, letterSpacing = (-0.03).em, - ) + ), ) { append(timestamp) } @@ -66,8 +66,8 @@ fun ChatMessageText( withStyle( SpanStyle( color = defaultNameColor, - fontWeight = FontWeight.Bold - ) + fontWeight = FontWeight.Bold, + ), ) { append(nameText) } @@ -81,8 +81,8 @@ fun ChatMessageText( // Add message text withStyle( SpanStyle( - color = if (isAction) defaultNameColor else defaultTextColor - ) + color = if (isAction) defaultNameColor else defaultTextColor, + ), ) { append(text) } @@ -93,7 +93,7 @@ fun ChatMessageText( BasicText( text = annotatedString, modifier = Modifier.fillMaxWidth(), - inlineContent = inlineContent + inlineContent = inlineContent, ) } } 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 index 9808f664b..5aa41b794 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -249,11 +249,7 @@ data class EmoteUi( * UI state for reply threads */ @Immutable -data class ThreadUi( - val rootId: String, - val userName: String, - val message: String, -) +data class ThreadUi(val rootId: String, val userName: String, val message: String) /** * Converts MessageThreadHeader to ThreadUi 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 index 719ddc7d2..bf3548baf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -107,7 +107,7 @@ fun ChatScreen( val isAtBottom by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 && - listState.firstVisibleItemScrollOffset == 0 + listState.firstVisibleItemScrollOffset == 0 } } @@ -150,7 +150,7 @@ fun ChatScreen( Surface( modifier = modifier.fillMaxSize(), - color = containerColor + color = containerColor, ) { Box(modifier = Modifier.fillMaxSize()) { LazyColumn( @@ -159,24 +159,24 @@ fun ChatScreen( contentPadding = contentPadding, modifier = Modifier .fillMaxSize() - .then(scrollModifier) + .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.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" + is ChatMessageUiState.DateSeparatorUi -> "datesep" } - } + }, ) { index, message -> // reverseLayout=true: index 0 = bottom (newest), index+1 = visually above val highlightedBelow = reversedMessages.getOrNull(index - 1)?.isHighlighted == true @@ -205,18 +205,18 @@ fun ChatScreen( val fabBottomPadding by animateDpAsState( targetValue = bottomContentPadding, animationSpec = if (showInput) snap() else spring(), - label = "fabBottomPadding" + label = "fabBottomPadding", ) val recoveryBottomPadding by animateDpAsState( targetValue = if (showScrollFab) 56.dp + 12.dp else 0.dp, - label = "recoveryBottomPadding" + label = "recoveryBottomPadding", ) Box( modifier = Modifier .align(Alignment.BottomEnd) .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + fabBottomPadding), - contentAlignment = Alignment.BottomEnd + contentAlignment = Alignment.BottomEnd, ) { if (recoveryFabTooltipState != null) { TooltipBox( @@ -236,7 +236,7 @@ fun ChatScreen( isFullscreen = isFullscreen, showInput = showInput, onRecover = onRecover, - modifier = Modifier.padding(bottom = recoveryBottomPadding) + modifier = Modifier.padding(bottom = recoveryBottomPadding), ) } } else { @@ -244,7 +244,7 @@ fun ChatScreen( isFullscreen = isFullscreen, showInput = showInput, onRecover = onRecover, - modifier = Modifier.padding(bottom = recoveryBottomPadding) + modifier = Modifier.padding(bottom = recoveryBottomPadding), ) } AnimatedVisibility( @@ -261,7 +261,7 @@ fun ChatScreen( ) { Icon( imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "Scroll to bottom" + contentDescription = "Scroll to bottom", ) } } @@ -272,27 +272,22 @@ fun ChatScreen( } @Composable -private fun RecoveryFab( - isFullscreen: Boolean, - showInput: Boolean, - onRecover: () -> Unit, - modifier: Modifier = Modifier -) { +private fun RecoveryFab(isFullscreen: Boolean, showInput: Boolean, onRecover: () -> Unit, modifier: Modifier = Modifier) { val visible = isFullscreen || !showInput AnimatedVisibility( visible = visible, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut(), - modifier = modifier + modifier = modifier, ) { SmallFloatingActionButton( onClick = onRecover, containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ) { Icon( imageVector = Icons.Default.FullscreenExit, - contentDescription = stringResource(R.string.menu_exit_fullscreen) + contentDescription = stringResource(R.string.menu_exit_fullscreen), ) } } @@ -313,44 +308,37 @@ private fun ChatMessageUiState.highlightShape(highlightedAbove: Boolean, highlig */ @Composable -private fun ChatMessageItem( - message: ChatMessageUiState, - highlightShape: Shape, - fontSize: Float, - showChannelPrefix: Boolean, - animateGifs: Boolean, - callbacks: ChatScreenCallbacks, -) { +private fun ChatMessageItem(message: ChatMessageUiState, highlightShape: Shape, fontSize: Float, showChannelPrefix: Boolean, animateGifs: Boolean, callbacks: ChatScreenCallbacks) { when (message) { - is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( + is ChatMessageUiState.SystemMessageUi -> SystemMessageComposable( message = message, - fontSize = fontSize + fontSize = fontSize, ) - is ChatMessageUiState.NoticeMessageUi -> NoticeMessageComposable( + is ChatMessageUiState.NoticeMessageUi -> NoticeMessageComposable( message = message, - fontSize = fontSize + fontSize = fontSize, ) - is ChatMessageUiState.UserNoticeMessageUi -> UserNoticeMessageComposable( + is ChatMessageUiState.UserNoticeMessageUi -> UserNoticeMessageComposable( message = message, highlightShape = highlightShape, - fontSize = fontSize + fontSize = fontSize, ) - is ChatMessageUiState.ModerationMessageUi -> ModerationMessageComposable( + is ChatMessageUiState.ModerationMessageUi -> ModerationMessageComposable( message = message, - fontSize = fontSize + fontSize = fontSize, ) - is ChatMessageUiState.AutomodMessageUi -> AutomodMessageComposable( + is ChatMessageUiState.AutomodMessageUi -> AutomodMessageComposable( message = message, fontSize = fontSize, onAllow = callbacks.onAutomodAllow, onDeny = callbacks.onAutomodDeny, ) - is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( + is ChatMessageUiState.PrivMessageUi -> PrivMessageComposable( message = message, highlightShape = highlightShape, fontSize = fontSize, @@ -359,21 +347,21 @@ private fun ChatMessageItem( onUserClick = callbacks.onUserClick, onMessageLongClick = callbacks.onMessageLongClick, onEmoteClick = callbacks.onEmoteClick, - onReplyClick = callbacks.onReplyClick + onReplyClick = callbacks.onReplyClick, ) is ChatMessageUiState.PointRedemptionMessageUi -> PointRedemptionMessageComposable( message = message, highlightShape = highlightShape, - fontSize = fontSize + fontSize = fontSize, ) - is ChatMessageUiState.DateSeparatorUi -> DateSeparatorComposable( + is ChatMessageUiState.DateSeparatorUi -> DateSeparatorComposable( message = message, - fontSize = fontSize + fontSize = fontSize, ) - is ChatMessageUiState.WhisperMessageUi -> WhisperMessageComposable( + is ChatMessageUiState.WhisperMessageUi -> WhisperMessageComposable( message = message, fontSize = fontSize, animateGifs = animateGifs, @@ -384,7 +372,7 @@ private fun ChatMessageItem( callbacks.onMessageLongClick(messageId, null, fullMessage) }, onEmoteClick = callbacks.onEmoteClick, - onWhisperReply = callbacks.onWhisperReply + onWhisperReply = callbacks.onWhisperReply, ) } } 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 index f2fa491fe..e9c29a8db 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt @@ -20,12 +20,7 @@ import androidx.compose.ui.input.pointer.positionChange * * 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 { +class ScrollDirectionTracker(private val hideThresholdPx: Float, private val showThresholdPx: Float, private val onHide: () -> Unit, private val onShow: () -> Unit) : NestedScrollConnection { private var accumulated = 0f override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { @@ -39,12 +34,14 @@ class ScrollDirectionTracker( } accumulated += delta when { - accumulated > hideThresholdPx -> { - onHide(); accumulated = 0f + accumulated > hideThresholdPx -> { + onHide() + accumulated = 0f } accumulated < -showThresholdPx -> { - onShow(); accumulated = 0f + onShow() + accumulated = 0f } } return Offset.Zero @@ -56,11 +53,7 @@ class ScrollDirectionTracker( * 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 { +fun Modifier.swipeDownToHide(enabled: Boolean, thresholdPx: Float, onHide: () -> Unit): Modifier { if (!enabled) return this return this.pointerInput(enabled) { awaitEachGesture { 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 index fcb5c26bb..3f8f33700 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -55,21 +55,22 @@ class ChatViewModel( private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { - - val chatDisplaySettings: StateFlow = combine( - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings, - ) { appearance, chat -> - ChatDisplaySettings( - fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, - animateGifs = chat.animateGifs, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) - - private val chat: StateFlow> = chatMessageRepository - .getChat(channel) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + 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 = HashMap(256) @@ -78,79 +79,89 @@ class ChatViewModel( 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.clear() - lastAppearanceSettings = appearanceSettings - lastChatSettings = chatSettings - } - - val zone = ZoneId.systemDefault() - val result = ArrayList(messages.size + 8) - var messageCount = 0 - - for (index in messages.indices) { - val item = messages[index] - val isAlternateBackground = when (index) { - messages.lastIndex -> messageCount++.isEven - else -> (index - messages.size - 1).isEven + 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.clear() + lastAppearanceSettings = appearanceSettings + lastChatSettings = chatSettings } - val altBg = isAlternateBackground && appearanceSettings.checkeredMessages - val cacheKey = "${item.message.id}-${item.tag}-$altBg" - val mapped = mappingCache.getOrPut(cacheKey) { - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg - ) - } - result += mapped + val zone = ZoneId.systemDefault() + val result = ArrayList(messages.size + 8) + var messageCount = 0 - // 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 + for (index in messages.indices) { + val item = messages[index] + val isAlternateBackground = + when (index) { + messages.lastIndex -> messageCount++.isEven + else -> (index - messages.size - 1).isEven + } + val altBg = isAlternateBackground && appearanceSettings.checkeredMessages + val cacheKey = "${item.message.id}-${item.tag}-$altBg" + + val mapped = + mappingCache.getOrPut(cacheKey) { + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, ) - } else { - "" } - result += ChatMessageUiState.DateSeparatorUi( - id = "date-sep-$nextDay", - timestamp = timestamp, - dateText = nextDay.format(dateFormatter), - ) + 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), + ) + } } } - } - result.toImmutableList() - }.flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), persistentListOf()) + result.toImmutableList() + }.flowOn(Dispatchers.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) + helixApiClient + .manageAutomodMessage(userId, heldMessageId, action) .onFailure { error -> Log.e(TAG, "Failed to $action automod message $heldMessageId", error) val statusCode = (error as? HelixApiException)?.status?.value chatMessageRepository.addSystemMessage( channel, - SystemMessageType.AutomodActionFailed(statusCode = statusCode, allow = allow) + SystemMessageType.AutomodActionFailed(statusCode = statusCode, allow = allow), ) } } @@ -162,8 +173,4 @@ class ChatViewModel( } @Immutable -data class ChatDisplaySettings( - val fontSize: Float = 14f, - val showLineSeparator: Boolean = false, - val animateGifs: Boolean = true, -) +data class ChatDisplaySettings(val fontSize: Float = 14f, val showLineSeparator: Boolean = false, val animateGifs: Boolean = true) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt index 469aaef82..c5604ed07 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt @@ -18,23 +18,20 @@ import com.flxrs.dankchat.utils.extensions.setRunning /** * Coordinates emote loading and animation synchronization across the entire chat. - * + * * Based on the old ChatAdapter/EmoteRepository approach: * - Uses LruCache to cache drawables (bounded memory, unlike ConcurrentHashMap) * - Shares Drawable instances across all usages of the same emote * - This keeps animated GIF frame counters synchronized naturally * - No mutex needed - Coil handles concurrent requests internally * - Emote animation controlled via setRunning() based on animateGifs setting - * + * * Same pattern as: * - EmoteRepository.badgeCache: LruCache(64) * - EmoteRepository.layerCache: LruCache(256) */ @Stable -class EmoteAnimationCoordinator( - val imageLoader: ImageLoader, - private val platformContext: PlatformContext, -) { +class EmoteAnimationCoordinator(val imageLoader: ImageLoader, private val platformContext: PlatformContext) { // LruCache for single emote drawables (like badgeCache in EmoteRepository) private val emoteCache = LruCache(256) @@ -46,7 +43,7 @@ class EmoteAnimationCoordinator( /** * Get or load an emote drawable. - * + * * Returns cached drawable if available, otherwise loads and caches it. * Sharing the same Drawable instance keeps animations synchronized. */ @@ -62,9 +59,11 @@ class EmoteAnimationCoordinator( // Load the emote via Coil (Coil handles concurrent requests internally) return try { - val request = ImageRequest.Builder(platformContext) - .data(url) - .build() + val request = + ImageRequest + .Builder(platformContext) + .data(url) + .build() val result = imageLoader.execute(request) if (result is SuccessResult) { @@ -128,9 +127,10 @@ class EmoteAnimationCoordinator( * Must be provided at the chat root (e.g., ChatComposable) so all messages * share the same coordinator and its LruCache. */ -val LocalEmoteAnimationCoordinator = staticCompositionLocalOf { - error("No EmoteAnimationCoordinator provided. Wrap your chat composables with CompositionLocalProvider.") -} +val LocalEmoteAnimationCoordinator = + staticCompositionLocalOf { + error("No EmoteAnimationCoordinator provided. Wrap your chat composables with CompositionLocalProvider.") + } /** * Creates and remembers a singleton EmoteAnimationCoordinator using the given ImageLoader. diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt index a1715c238..d092290c8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt @@ -20,25 +20,27 @@ import androidx.compose.ui.unit.LayoutDirection * the callback chain so animations continue after scrolling off/on screen. */ @Stable -class EmoteDrawablePainter(val drawable: Drawable) : Painter(), androidx.compose.runtime.RememberObserver { - +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++ - } + 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 scheduleDrawable(d: Drawable, what: Runnable, time: Long) { + mainHandler.postAtTime(what, time) + } - override fun unscheduleDrawable(d: Drawable, what: Runnable) { - mainHandler.removeCallbacks(what) + override fun unscheduleDrawable(d: Drawable, what: Runnable) { + mainHandler.removeCallbacks(what) + } } - } override val intrinsicSize: Size get() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt index eb02ee27a..25d8cf681 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt @@ -7,13 +7,13 @@ import kotlin.math.roundToInt /** * Emote scaling utilities that match the original ChatAdapter logic EXACTLY. - * + * * Old ChatAdapter constants: * - BASE_HEIGHT_CONSTANT = 1.173 * - SCALE_FACTOR_CONSTANT = 1.5 / 112 * - baseHeight = textSize * BASE_HEIGHT_CONSTANT * - scaleFactor = baseHeight * SCALE_FACTOR_CONSTANT - * + * * This ensures 100% visual parity with the old TextView-based rendering. */ object EmoteScaling { @@ -24,51 +24,43 @@ object EmoteScaling { * Calculate base emote height from font size. * This matches the line height of text. */ - fun getBaseHeight(fontSizeSp: Float): Dp { - return (fontSizeSp * BASE_HEIGHT_CONSTANT).dp - } + fun getBaseHeight(fontSizeSp: Float): Dp = (fontSizeSp * BASE_HEIGHT_CONSTANT).dp /** * Calculate scale factor from base height in pixels. */ - fun getScaleFactor(baseHeightPx: Int): Double { - return baseHeightPx * SCALE_FACTOR_CONSTANT - } + fun getScaleFactor(baseHeightPx: Int): Double = baseHeightPx * SCALE_FACTOR_CONSTANT /** * Calculate scaled emote dimensions matching old ChatAdapter.transformEmoteDrawable() EXACTLY. - * + * * Old logic: * 1. ratio = intrinsicWidth / intrinsicHeight * 2. height = special handling for Twitch emotes, else intrinsicHeight * scale * 3. width = height * ratio * 4. scaledWidth = width * emote.scale * 5. scaledHeight = height * emote.scale - * + * * Returns pixel dimensions. - * + * * @param intrinsicWidth Original emote width in pixels - * @param intrinsicHeight Original emote height in pixels + * @param intrinsicHeight Original emote height in pixels * @param emote The emote with scale factor and type info * @param baseHeightPx Base height in pixels (line height) * @return Pair of (widthPx, heightPx) in pixels */ - fun calculateEmoteDimensionsPx( - intrinsicWidth: Int, - intrinsicHeight: Int, - emote: ChatMessageEmote, - baseHeightPx: Int - ): Pair { + fun calculateEmoteDimensionsPx(intrinsicWidth: Int, intrinsicHeight: Int, emote: ChatMessageEmote, baseHeightPx: Int): Pair { val scale = baseHeightPx * SCALE_FACTOR_CONSTANT val ratio = intrinsicWidth / intrinsicHeight.toFloat() // Match ChatAdapter height calculation exactly - val height = when { - intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() - intrinsicHeight in 55..111 && emote.isTwitch -> (112 * scale).roundToInt() - else -> (intrinsicHeight * scale).roundToInt() - } + 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() // Apply individual emote scale @@ -82,7 +74,5 @@ object EmoteScaling { * Calculate badge dimensions. * Badges are always square at the base height. */ - fun getBadgeSize(fontSizeSp: Float): Dp { - return getBaseHeight(fontSizeSp) - } + fun getBadgeSize(fontSizeSp: Float): Dp = getBaseHeight(fontSizeSp) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt index f00ce7195..e744ef36e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt @@ -37,10 +37,11 @@ fun AnnotatedString.Builder.appendWithLinks(text: String, linkColor: Color, prev end = fixedEnd val rawUrl = text.substring(start, end) - val url = when { - rawUrl.contains("://") -> rawUrl - else -> "https://$rawUrl" - } + val url = + when { + rawUrl.contains("://") -> rawUrl + else -> "https://$rawUrl" + } // Append text before URL if (start > lastIndex) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt index 1f7e7ec70..ecfa1c121 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt @@ -25,7 +25,7 @@ import kotlin.math.roundToInt /** * Renders stacked emotes exactly like old ChatAdapter using LayerDrawable. - * + * * Key differences from previous approaches: * - Creates actual LayerDrawable like ChatAdapter did * - Uses LruCache for LayerDrawables (not individual drawables) @@ -58,7 +58,7 @@ fun StackedEmote( animateGifs = animateGifs, alpha = alpha, modifier = modifier, - onClick = onClick + onClick = onClick, ) return } @@ -104,7 +104,7 @@ fun StackedEmote( // Store dimensions for future placeholder sizing emoteCoordinator.dimensionCache.put( cacheKey, - layerDrawable.bounds.width() to layerDrawable.bounds.height() + layerDrawable.bounds.width() to layerDrawable.bounds.height(), ) value = layerDrawable // Control animation @@ -131,7 +131,7 @@ fun StackedEmote( alpha = alpha, modifier = modifier .size(width = widthDp, height = heightDp) - .clickable { onClick() } + .clickable { onClick() }, ) } else { // Placeholder with estimated size to prevent layout shift @@ -140,7 +140,7 @@ fun StackedEmote( Box( modifier = modifier .size(width = widthDp, height = heightDp) - .clickable { onClick() } + .clickable { onClick() }, ) } } @@ -185,7 +185,7 @@ private fun SingleEmoteDrawable( // Store dimensions for future placeholder sizing emoteCoordinator.dimensionCache.put( url, - transformed.bounds.width() to transformed.bounds.height() + transformed.bounds.width() to transformed.bounds.height(), ) value = transformed } @@ -215,7 +215,7 @@ private fun SingleEmoteDrawable( alpha = alpha, modifier = modifier .size(width = widthDp, height = heightDp) - .clickable { onClick() } + .clickable { onClick() }, ) } else if (cachedDims != null) { // Placeholder with cached size to prevent layout shift @@ -224,7 +224,7 @@ private fun SingleEmoteDrawable( Box( modifier = modifier .size(width = widthDp, height = heightDp) - .clickable { onClick() } + .clickable { onClick() }, ) } } @@ -233,13 +233,7 @@ private fun SingleEmoteDrawable( * Transform emote drawable exactly like old ChatAdapter.transformEmoteDrawable(). * Phase 1: Individual scaling without maxWidth/maxHeight. */ -private fun transformEmoteDrawable( - drawable: Drawable, - scale: Double, - emote: ChatMessageEmote, - maxWidth: Int = 0, - maxHeight: Int = 0 -): Drawable { +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() @@ -261,10 +255,7 @@ private fun transformEmoteDrawable( /** * Create LayerDrawable from array of drawables exactly like old ChatAdapter.toLayerDrawable(). */ -private fun Array.toLayerDrawable( - scaleFactor: Double, - emotes: List -): LayerDrawable = LayerDrawable(this).apply { +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() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt index 535bfdd61..4a43933b6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt @@ -26,26 +26,22 @@ import kotlinx.coroutines.launch /** * Data class to hold measured emote dimensions */ -data class EmoteDimensions( - val id: String, - val widthPx: Int, - val heightPx: Int -) +data class EmoteDimensions(val id: String, val widthPx: Int, val heightPx: Int) /** * Renders text with inline images (badges, emotes) using SubcomposeLayout. - * + * * This solves the fundamental problem with InlineTextContent: we need to know * the size of images before creating Placeholder objects, but images load asynchronously. - * + * * SubcomposeLayout allows us to: * 1. First measure all inline images to get their actual dimensions * 2. Create InlineTextContent with correct Placeholder sizes * 3. Finally compose the text with properly sized placeholders - * + * * This maintains natural text flow (like TextView) while supporting variable-sized * inline content (like ImageSpans with different drawable sizes). - * + * * @param text The AnnotatedString with annotations marking where inline content goes * @param inlineContentProviders Map of content IDs to composables that will be measured * @param modifier Modifier for the text @@ -86,13 +82,13 @@ fun TextWithMeasuredInlineContent( val placeable = measurables.first().measure( Constraints( maxWidth = constraints.maxWidth, - maxHeight = Constraints.Infinity - ) + maxHeight = Constraints.Infinity, + ), ) measuredDimensions[id] = EmoteDimensions( id = id, widthPx = placeable.width, - heightPx = placeable.height + heightPx = placeable.height, ) } } @@ -104,8 +100,8 @@ fun TextWithMeasuredInlineContent( placeholder = Placeholder( width = with(density) { dimensions.widthPx.toDp() }.value.sp, height = with(density) { dimensions.heightPx.toDp() }.value.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter - ) + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), ) { // Render the actual content (re-compose with same provider) inlineContentProviders[id]?.invoke() @@ -157,12 +153,12 @@ fun TextWithMeasuredInlineContent( } else { onTextLongClick?.invoke(-1) } - } + }, ) }, onTextLayout = { layoutResult -> textLayoutResultRef.value = layoutResult - } + }, ) } @@ -184,15 +180,11 @@ fun TextWithMeasuredInlineContent( * Use this when you already have the dimensions or don't need click handling. */ @Composable -fun MeasuredInlineText( - text: AnnotatedString, - inlineContent: Map, - modifier: Modifier = Modifier, -) { +fun MeasuredInlineText(text: AnnotatedString, inlineContent: Map, modifier: Modifier = Modifier) { Box(modifier = modifier) { BasicText( text = text, - inlineContent = inlineContent + inlineContent = inlineContent, ) } } 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 index ca2032c4c..25c026372 100644 --- 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 @@ -9,69 +9,63 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @KoinViewModel -class EmoteInfoViewModel( - @InjectedParam private val emotes: List, -) : ViewModel() { +class EmoteInfoViewModel(@InjectedParam private val emotes: List) : ViewModel() { + val items = + 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(), + ) + } - val items = 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? = when (type) { + is ChatMessageEmoteType.GlobalSevenTVEmote -> type.baseName + is ChatMessageEmoteType.ChannelSevenTVEmote -> type.baseName + else -> null } - 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? = 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.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 = when (type) { + is ChatMessageEmoteType.GlobalSevenTVEmote, + is ChatMessageEmoteType.ChannelSevenTVEmote, + -> "$SEVEN_TV_BASE_LINK$id" - 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.ChannelBTTVEmote, - is ChatMessageEmoteType.GlobalBTTVEmote -> "$BTTV_BASE_LINK$id" + is ChatMessageEmoteType.ChannelFFZEmote, + is ChatMessageEmoteType.GlobalFFZEmote, + -> "$FFZ_BASE_LINK$id-$code" - is ChatMessageEmoteType.ChannelFFZEmote, - is ChatMessageEmoteType.GlobalFFZEmote -> "$FFZ_BASE_LINK$id-$code" + is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" - is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" - is ChatMessageEmoteType.Cheermote -> "$TWITCH_BASE_LINK$id" - } + is ChatMessageEmoteType.Cheermote -> "$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 - ChatMessageEmoteType.Cheermote -> R.string.emote_sheet_twitch_emote - } + 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 { 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 index 47ea3742a..45dfb5b2b 100644 --- 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 @@ -5,12 +5,12 @@ 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 { - return when (val byType = emote.emoteType.compareTo(other.emote.emoteType)) { - 0 -> other.emote.code.compareTo(other.emote.code) - else -> byType - } + 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 } } @@ -22,5 +22,6 @@ sealed class EmoteItem { } 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/EmoteMenuTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTab.kt index f7d00b678..d25ade537 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTab.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTab.kt @@ -4,5 +4,5 @@ enum class EmoteMenuTab { RECENT, SUBS, CHANNEL, - GLOBAL + GLOBAL, } 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 index e113ad832..0eda19979 100644 --- 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 @@ -55,73 +55,79 @@ class MessageHistoryViewModel( private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { - - val chatDisplaySettings: StateFlow = combine( - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings, - ) { appearance, chat -> - ChatDisplaySettings( - fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, - animateGifs = chat.animateGifs, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) val searchFieldState = TextFieldState() - private val searchQuery = snapshotFlow { searchFieldState.text.toString() } - .distinctUntilChanged() + 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 filters: Flow> = + merge( + searchQuery.take(1), + searchQuery.drop(1).debounce(300), + ).map { ChatSearchFilterParser.parse(it) } + .distinctUntilChanged() - val historyUiStates: Flow> = combine( - chatMessageRepository.getChat(channel), - filters, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings, - ) { messages, activeFilters, appearanceSettings, chatSettings -> - messages - .filter { ChatItemFilter.matches(it, activeFilters) } - .mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) - } - }.flowOn(Dispatchers.Default) + val historyUiStates: Flow> = + combine( + chatMessageRepository.getChat(channel), + filters, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, activeFilters, appearanceSettings, chatSettings -> + messages + .filter { ChatItemFilter.matches(it, activeFilters) } + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + } + }.flowOn(Dispatchers.Default) - private val users: StateFlow> = usersRepository.getUsersFlow(channel) - .map { it.toImmutableSet() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) + private val users: StateFlow> = + usersRepository + .getUsersFlow(channel) + .map { it.toImmutableSet() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) - private val badgeNames: StateFlow> = chatMessageRepository.getChat(channel) - .map { items -> - items.asSequence() - .map { it.message } - .filterIsInstance() - .flatMap { it.badges } - .mapNotNull { it.badgeTag?.substringBefore('/') } - .toImmutableSet() - } - .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) + private val badgeNames: StateFlow> = + chatMessageRepository + .getChat(channel) + .map { items -> + items + .asSequence() + .map { it.message } + .filterIsInstance() + .flatMap { it.badges } + .mapNotNull { it.badgeTag?.substringBefore('/') } + .toImmutableSet() + }.flowOn(Dispatchers.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()) + 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()) { @@ -143,15 +149,17 @@ class MessageHistoryViewModel( 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 prefix = + when { + lastSpaceIndex >= 0 -> currentText.substring(0, lastSpaceIndex + 1) + else -> "" + } val keyword = suggestion.toString() - val suffix = when { - keyword.endsWith(':') -> "" - else -> " " - } + val suffix = + when { + keyword.endsWith(':') -> "" + else -> " " + } val newText = prefix + keyword + suffix searchFieldState.edit { replace(0, length, newText) 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 index 68ae01cbc..ce97b3c12 100644 --- 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 @@ -20,7 +20,7 @@ import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator /** * 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 @@ -43,7 +43,7 @@ fun MentionComposable( val displaySettings by mentionViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val messages by when { isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) - else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) } val context = LocalPlatformContext.current 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 index 754b29266..18668237c 100644 --- 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 @@ -33,17 +33,17 @@ class MentionViewModel( private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { - - val chatDisplaySettings: StateFlow = combine( - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings, - ) { appearance, chat -> - ChatDisplaySettings( - fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, - animateGifs = chat.animateGifs, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) private val _currentTab = MutableStateFlow(0) val currentTab: StateFlow = _currentTab @@ -52,47 +52,53 @@ class MentionViewModel( _currentTab.value = index } - 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()) + 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()) - val mentionsUiStates: Flow> = combine( - mentions, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings, - ) { messages, appearanceSettings, chatSettings -> - messages.mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg - ) - } - }.flowOn(Dispatchers.Default) + val mentionsUiStates: Flow> = + combine( + mentions, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, appearanceSettings, chatSettings -> + messages.mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + } + }.flowOn(Dispatchers.Default) - val whispersUiStates: Flow> = combine( - whispers, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings, - ) { messages, appearanceSettings, chatSettings -> - messages.mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg - ) - } - }.flowOn(Dispatchers.Default) + val whispersUiStates: Flow> = + combine( + whispers, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, appearanceSettings, chatSettings -> + messages.mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + } + }.flowOn(Dispatchers.Default) - val hasMentions: StateFlow = chatNotificationRepository.hasMentions - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) - val hasWhispers: StateFlow = chatNotificationRepository.hasWhispers - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) + val hasMentions: StateFlow = + chatNotificationRepository.hasMentions + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) + val hasWhispers: StateFlow = + chatNotificationRepository.hasWhispers + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), 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 index fcb58b66c..6f84919bd 100644 --- 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 @@ -6,7 +6,9 @@ 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, 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 index 430f5fd1b..018a8f476 100644 --- 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 @@ -39,39 +39,42 @@ class MessageOptionsViewModel( private val commandRepository: CommandRepository, private val repliesRepository: RepliesRepository, ) : 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 replyName = name - val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage - MessageOptionsState.Found( - messageId = message.id, - rootThreadId = rootId ?: message.id, - rootThreadName = thread?.name, - replyName = replyName, - 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 - ) + 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 replyName = name + val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage + MessageOptionsState.Found( + messageId = message.id, + rootThreadId = rootId ?: message.id, + rootThreadName = thread?.name, + replyName = replyName, + 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) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MessageOptionsState.Loading) fun timeoutUser(index: Int) = viewModelScope.launch { val duration = TIMEOUT_MAP[index] ?: return@launch @@ -97,28 +100,30 @@ class MessageOptionsViewModel( 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 + val result = + runCatching { + commandRepository.checkForCommands(message, activeChannel, roomState, userState) + }.getOrNull() ?: return when (result) { - is CommandResult.IrcCommand -> chatRepository.sendMessage(message, forceIrc = true) + is CommandResult.IrcCommand -> chatRepository.sendMessage(message, forceIrc = true) is CommandResult.AcceptedTwitchCommand -> result.response?.let { chatRepository.makeAndPostCustomSystemMessage(it, activeChannel) } - else -> Unit + 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", - ) + 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 index f573659aa..3e3ff3f4b 100644 --- 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 @@ -82,7 +82,7 @@ fun AutomodMessageComposable( fontSize = textSize * 0.95f, color = timestampColor, letterSpacing = (-0.03).em, - ) + ), ) { append(message.timestamp) append(" ") @@ -103,20 +103,20 @@ fun AutomodMessageComposable( when { // User-side: simple status messages, no Allow/Deny message.isUserSide -> when (message.status) { - AutomodMessageStatus.Pending -> withStyle(SpanStyle(color = textColor)) { append(userHeldText) } + 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) } + 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 -> { + else -> { withStyle(SpanStyle(color = textColor)) { append("$headerText ") } when (message.status) { - AutomodMessageStatus.Pending -> { + AutomodMessageStatus.Pending -> { pushStringAnnotation(tag = ALLOW_TAG, annotation = message.heldMessageId) withStyle(SpanStyle(color = allowColor, fontWeight = FontWeight.Bold)) { append(allowText) @@ -136,13 +136,13 @@ fun AutomodMessageComposable( } } - AutomodMessageStatus.Denied -> { + AutomodMessageStatus.Denied -> { withStyle(SpanStyle(color = denyColor.copy(alpha = 0.6f), fontWeight = FontWeight.Bold)) { append(deniedText) } } - AutomodMessageStatus.Expired -> { + AutomodMessageStatus.Expired -> { withStyle(SpanStyle(color = textColor.copy(alpha = 0.5f), fontWeight = FontWeight.Bold)) { append(expiredText) } @@ -166,7 +166,7 @@ fun AutomodMessageComposable( fontSize = textSize * 0.95f, color = timestampColor, letterSpacing = (-0.03).em, - ) + ), ) { append(message.timestamp) append(" ") @@ -209,9 +209,9 @@ fun AutomodMessageComposable( } val resolvedAlpha = when { - message.isUserSide -> 1f + message.isUserSide -> 1f message.status == AutomodMessageStatus.Pending -> 1f - else -> 0.5f + else -> 0.5f } Column( @@ -219,7 +219,7 @@ fun AutomodMessageComposable( .fillMaxWidth() .wrapContentHeight() .alpha(resolvedAlpha) - .padding(horizontal = 2.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { // Header line with badge inline content TextWithMeasuredInlineContent( 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 index f67b2542f..97d34a75d 100644 --- 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 @@ -81,7 +81,7 @@ fun PrivMessageComposable( .alpha(message.textAlpha) .background(backgroundColor, highlightShape) .indication(interactionSource, ripple()) - .padding(horizontal = 2.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { // Highlight type header (First Time Chat, Elevated Chat) if (message.highlightHeader != null) { @@ -89,14 +89,14 @@ fun PrivMessageComposable( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically + 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 + tint = headerColor, ) Text( text = message.highlightHeader.resolve(), @@ -104,7 +104,7 @@ fun PrivMessageComposable( fontWeight = FontWeight.Medium, color = headerColor, maxLines = 1, - modifier = Modifier.padding(start = 4.dp) + modifier = Modifier.padding(start = 4.dp), ) } } @@ -116,14 +116,14 @@ fun PrivMessageComposable( .fillMaxWidth() .clickable { onReplyClick(message.thread.rootId, message.thread.userName.toUserName()) } .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { val replyColor = rememberAdaptiveTextColor(backgroundColor).copy(alpha = 0.6f) Icon( imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = null, modifier = Modifier.size(16.dp), - tint = replyColor + tint = replyColor, ) Text( text = "Reply to @${message.thread.userName}: ${message.thread.message}", @@ -131,7 +131,7 @@ fun PrivMessageComposable( color = replyColor, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } } @@ -146,7 +146,7 @@ fun PrivMessageComposable( backgroundColor = backgroundColor, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick + onEmoteClick = onEmoteClick, ) } } @@ -176,8 +176,8 @@ private fun PrivMessageText( withStyle( SpanStyle( fontWeight = FontWeight.Bold, - color = defaultTextColor - ) + color = defaultTextColor, + ), ) { append("#${message.channel.value} ") } @@ -202,12 +202,12 @@ private fun PrivMessageText( withStyle( SpanStyle( fontWeight = FontWeight.Bold, - color = nameColor - ) + color = nameColor, + ), ) { pushStringAnnotation( tag = "USER", - annotation = "${message.userId?.value ?: ""}|${message.userName.value}|${message.displayName.value}|${message.channel.value}" + annotation = "${message.userId?.value ?: ""}|${message.userName.value}|${message.displayName.value}|${message.channel.value}", ) append(message.nameText) pop() @@ -240,7 +240,7 @@ private fun PrivMessageText( SpanStyle( color = emote.cheerColor ?: textColor, fontWeight = FontWeight.Bold, - ) + ), ) { append(emote.cheerAmount.toString()) } @@ -292,7 +292,7 @@ private fun PrivMessageText( when { user != null -> onUserClick(user.userId, user.userName, user.displayName, user.channel.orEmpty(), message.badges, true) - else -> onMessageLongClick(message.id, message.channel.value, message.fullMessage) + 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 index f7285cf3c..435857a93 100644 --- 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 @@ -37,11 +37,7 @@ 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, -) { +fun SystemMessageComposable(message: ChatMessageUiState.SystemMessageUi, fontSize: Float, modifier: Modifier = Modifier) { SimpleMessageContainer( message = message.message.resolve(), timestamp = message.timestamp, @@ -57,11 +53,7 @@ fun SystemMessageComposable( * Renders a notice message from Twitch */ @Composable -fun NoticeMessageComposable( - message: ChatMessageUiState.NoticeMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { +fun NoticeMessageComposable(message: ChatMessageUiState.NoticeMessageUi, fontSize: Float, modifier: Modifier = Modifier) { SimpleMessageContainer( message = message.message, timestamp = message.timestamp, @@ -79,12 +71,7 @@ fun NoticeMessageComposable( */ @Suppress("DEPRECATION") @Composable -fun UserNoticeMessageComposable( - message: ChatMessageUiState.UserNoticeMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, - highlightShape: Shape = RectangleShape, -) { +fun UserNoticeMessageComposable(message: ChatMessageUiState.UserNoticeMessageUi, fontSize: Float, modifier: Modifier = Modifier, highlightShape: Shape = RectangleShape) { val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = MaterialTheme.colorScheme.onSurface val linkColor = MaterialTheme.colorScheme.primary @@ -108,7 +95,7 @@ fun UserNoticeMessageComposable( val msgText = message.message val nameIndex = when { displayName.isNotEmpty() -> msgText.indexOf(displayName, ignoreCase = true) - else -> -1 + else -> -1 } when { @@ -134,7 +121,7 @@ fun UserNoticeMessageComposable( } } - else -> { + else -> { // No display name found, render as plain text withStyle(SpanStyle(color = textColor)) { appendWithLinks(msgText, linkColor) @@ -150,7 +137,7 @@ fun UserNoticeMessageComposable( .wrapContentHeight() .alpha(message.textAlpha) .background(bgColor, highlightShape) - .padding(horizontal = 2.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { ClickableText( text = annotatedString, @@ -161,7 +148,7 @@ fun UserNoticeMessageComposable( .firstOrNull()?.let { annotation -> launchCustomTab(context, annotation.item) } - } + }, ) } } @@ -170,11 +157,7 @@ fun UserNoticeMessageComposable( * Renders a date separator between messages from different days */ @Composable -fun DateSeparatorComposable( - message: ChatMessageUiState.DateSeparatorUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { +fun DateSeparatorComposable(message: ChatMessageUiState.DateSeparatorUi, fontSize: Float, modifier: Modifier = Modifier) { SimpleMessageContainer( message = message.dateText, timestamp = message.timestamp, @@ -194,11 +177,7 @@ private data class StyledRange(val start: Int, val length: Int, val color: Color */ @Suppress("DEPRECATION") @Composable -fun ModerationMessageComposable( - message: ChatMessageUiState.ModerationMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, -) { +fun ModerationMessageComposable(message: ChatMessageUiState.ModerationMessageUi, fontSize: Float, modifier: Modifier = Modifier) { val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) val timestampColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) @@ -212,7 +191,7 @@ fun ModerationMessageComposable( val dimmedTextColor = textColor.copy(alpha = 0.7f) val annotatedString = remember( - message, resolvedMessage, textColor, dimmedTextColor, creatorColor, targetColor, linkColor, timestampColor, textSize + message, resolvedMessage, textColor, dimmedTextColor, creatorColor, targetColor, linkColor, timestampColor, textSize, ) { // Collect all highlighted ranges: usernames (bold+colored) and arguments (regular text color) val ranges = buildList { @@ -259,7 +238,7 @@ fun ModerationMessageComposable( } val style = when { range.bold -> SpanStyle(color = range.color, fontWeight = FontWeight.Bold) - else -> SpanStyle(color = range.color) + else -> SpanStyle(color = range.color) } withStyle(style) { append(resolvedMessage.substring(range.start, range.start + range.length)) @@ -280,7 +259,7 @@ fun ModerationMessageComposable( .wrapContentHeight() .alpha(message.textAlpha) .background(bgColor) - .padding(horizontal = 2.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { ClickableText( text = annotatedString, @@ -291,7 +270,7 @@ fun ModerationMessageComposable( .firstOrNull()?.let { annotation -> launchCustomTab(context, annotation.item) } - } + }, ) } } 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 index c91d9560f..ce29a4d01 100644 --- 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 @@ -72,7 +72,7 @@ fun WhisperMessageComposable( .alpha(message.textAlpha) .background(backgroundColor) .indication(interactionSource, ripple()) - .padding(horizontal = 2.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { Box(modifier = Modifier.weight(1f)) { WhisperMessageText( @@ -82,19 +82,19 @@ fun WhisperMessageComposable( backgroundColor = backgroundColor, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick + onEmoteClick = onEmoteClick, ) } if (onWhisperReply != null) { IconButton( onClick = { onWhisperReply(message.replyTargetName) }, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(28.dp), ) { Icon( imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = null, modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -138,12 +138,12 @@ private fun WhisperMessageText( withStyle( SpanStyle( fontWeight = FontWeight.Bold, - color = senderColor - ) + color = senderColor, + ), ) { pushStringAnnotation( tag = "USER", - annotation = "${message.userId.value}|${message.userName.value}|${message.displayName.value}" + annotation = "${message.userId.value}|${message.userName.value}|${message.displayName.value}", ) append(message.senderName) pop() @@ -156,8 +156,8 @@ private fun WhisperMessageText( withStyle( SpanStyle( fontWeight = FontWeight.Bold, - color = recipientColor - ) + color = recipientColor, + ), ) { append(message.recipientName) } @@ -224,7 +224,7 @@ private fun WhisperMessageText( when { user != null -> onUserClick(user.userId, user.userName, user.displayName, message.badges, true) - else -> onMessageLongClick(message.id, message.fullMessage) + else -> onMessageLongClick(message.id, message.fullMessage) } }, ) @@ -234,12 +234,7 @@ private fun WhisperMessageText( * Renders a channel point redemption message */ @Composable -fun PointRedemptionMessageComposable( - message: ChatMessageUiState.PointRedemptionMessageUi, - fontSize: Float, - modifier: Modifier = Modifier, - highlightShape: Shape = RectangleShape, -) { +fun PointRedemptionMessageComposable(message: ChatMessageUiState.PointRedemptionMessageUi, fontSize: Float, modifier: Modifier = Modifier, highlightShape: Shape = RectangleShape) { val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val timestampColor = rememberAdaptiveTextColor(backgroundColor) @@ -249,11 +244,11 @@ fun PointRedemptionMessageComposable( .wrapContentHeight() .alpha(message.textAlpha) .background(backgroundColor, highlightShape) - .padding(horizontal = 2.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { val annotatedString = remember(message, timestampColor) { buildAnnotatedString { @@ -270,7 +265,7 @@ fun PointRedemptionMessageComposable( append("Redeemed ") } - message.nameText != null -> { + message.nameText != null -> { withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(message.nameText) } @@ -288,19 +283,19 @@ fun PointRedemptionMessageComposable( BasicText( text = annotatedString, style = TextStyle(fontSize = fontSize.sp), - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) AsyncImage( model = message.rewardImageUrl, contentDescription = message.title, - modifier = Modifier.size((fontSize * 1.5f).dp) + modifier = Modifier.size((fontSize * 1.5f).dp), ) BasicText( text = " ${message.cost}", style = TextStyle(fontSize = fontSize.sp), - modifier = Modifier.padding(start = 4.dp) + modifier = Modifier.padding(start = 4.dp), ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt index 7f1a22815..708209698 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt @@ -24,22 +24,18 @@ private val FfzModGreen = Color(0xFF34AE0A) * 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 -) { +fun BadgeInlineContent(badge: BadgeUi, size: Dp, modifier: Modifier = Modifier) { when (badge.badge) { - is Badge.FFZModBadge -> { + is Badge.FFZModBadge -> { Box( modifier = modifier .size(size) - .background(FfzModGreen) + .background(FfzModGreen), ) { AsyncImage( model = badge.url, contentDescription = badge.badge.type.name, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) } } @@ -50,15 +46,15 @@ fun BadgeInlineContent( contentDescription = badge.badge.type.name, modifier = modifier .size(size) - .clip(CircleShape) + .clip(CircleShape), ) } - else -> { + else -> { AsyncImage( model = badge.drawableResId ?: badge.url, contentDescription = badge.badge.type.name, - modifier = modifier.size(size) + modifier = modifier.size(size), ) } } @@ -68,20 +64,13 @@ fun BadgeInlineContent( * Renders an emote (potentially stacked) as inline content in a message. */ @Composable -fun EmoteInlineContent( - emote: EmoteUi, - fontSize: Float, - coordinator: EmoteAnimationCoordinator, - animateGifs: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { +fun EmoteInlineContent(emote: EmoteUi, fontSize: Float, coordinator: EmoteAnimationCoordinator, animateGifs: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { StackedEmote( emote = emote, fontSize = fontSize, emoteCoordinator = coordinator, animateGifs = animateGifs, modifier = modifier, - onClick = onClick + onClick = onClick, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt index 22d576085..d47d4892a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt @@ -22,11 +22,7 @@ import com.flxrs.dankchat.ui.chat.EmoteUi /** * Appends a formatted timestamp to the AnnotatedString builder. */ -fun AnnotatedString.Builder.appendTimestamp( - timestamp: String, - fontSize: TextUnit, - color: Color -) { +fun AnnotatedString.Builder.appendTimestamp(timestamp: String, fontSize: TextUnit, color: Color) { if (timestamp.isNotEmpty()) { withStyle( SpanStyle( @@ -34,8 +30,8 @@ fun AnnotatedString.Builder.appendTimestamp( fontWeight = FontWeight.Bold, fontSize = (fontSize.value * 0.95f).sp, color = color, - letterSpacing = (-0.03).em - ) + letterSpacing = (-0.03).em, + ), ) { append(timestamp) append(" ") @@ -56,11 +52,7 @@ fun AnnotatedString.Builder.appendBadges(badges: List) { /** * Appends message text with emotes, handling emote inline content and spacing. */ -fun AnnotatedString.Builder.appendMessageWithEmotes( - message: String, - emotes: List, - textColor: Color -) { +fun AnnotatedString.Builder.appendMessageWithEmotes(message: String, emotes: List, textColor: Color) { withStyle(SpanStyle(color = textColor)) { var currentPos = 0 emotes.sortedBy { it.position.first }.forEach { emote -> @@ -78,7 +70,7 @@ fun AnnotatedString.Builder.appendMessageWithEmotes( SpanStyle( color = emote.cheerColor ?: textColor, fontWeight = FontWeight.Bold, - ) + ), ) { append(emote.cheerAmount.toString()) } @@ -103,29 +95,23 @@ fun AnnotatedString.Builder.appendMessageWithEmotes( /** * Appends a clickable username with annotation for click handling. */ -fun AnnotatedString.Builder.appendClickableUsername( - displayText: String, - userId: UserId?, - userName: UserName, - displayName: DisplayName, - channel: String = "", - color: Color -) { +fun AnnotatedString.Builder.appendClickableUsername(displayText: String, userId: UserId?, userName: UserName, displayName: DisplayName, channel: String = "", color: Color) { if (displayText.isNotEmpty()) { withStyle( SpanStyle( fontWeight = FontWeight.Bold, - color = color - ) + color = color, + ), ) { - val annotation = if (channel.isNotEmpty()) { - "${userId?.value ?: ""}|${userName.value}|${displayName.value}|$channel" - } else { - "${userId?.value ?: ""}|${userName.value}|${displayName.value}" - } + val annotation = + if (channel.isNotEmpty()) { + "${userId?.value ?: ""}|${userName.value}|${displayName.value}|$channel" + } else { + "${userId?.value ?: ""}|${userName.value}|${displayName.value}" + } pushStringAnnotation( tag = "USER", - annotation = annotation + annotation = annotation, ) append(displayText) pop() @@ -133,31 +119,32 @@ fun AnnotatedString.Builder.appendClickableUsername( } } -data class UserAnnotation( - val userId: String?, - val userName: String, - val displayName: String, - val channel: String?, -) +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 + 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 + } } } @@ -171,7 +158,7 @@ fun buildInlineContentProviders( fontSize: Float, coordinator: EmoteAnimationCoordinator, animateGifs: Boolean, - onEmoteClick: (List) -> Unit + onEmoteClick: (List) -> Unit, ): Map Unit> { val badgeSize = EmoteScaling.getBadgeSize(fontSize) @@ -191,7 +178,7 @@ fun buildInlineContentProviders( fontSize = fontSize, coordinator = coordinator, animateGifs = animateGifs, - onClick = { onEmoteClick(emotes) } + onClick = { onEmoteClick(emotes) }, ) } } 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 index 0e536ee55..b5c56d5c3 100644 --- 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 @@ -61,7 +61,7 @@ fun MessageTextWithInlineContent( emoteCoordinator = emoteCoordinator, animateGifs = animateGifs, modifier = Modifier, - onClick = { onEmoteClick(emote.emotes) } + onClick = { onEmoteClick(emote.emotes) }, ) } } @@ -87,7 +87,7 @@ fun MessageTextWithInlineContent( } } - else -> { + else -> { val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" val dims = emoteCoordinator.dimensionCache.get(cacheKey) if (dims != null) { 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 index fc187bc01..dea1d979c 100644 --- 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 @@ -30,15 +30,7 @@ import com.flxrs.dankchat.ui.chat.rememberBackgroundColor */ @Suppress("DEPRECATION") @Composable -fun SimpleMessageContainer( - message: String, - timestamp: String, - fontSize: TextUnit, - lightBackgroundColor: Color, - darkBackgroundColor: Color, - textAlpha: Float, - modifier: Modifier = Modifier, -) { +fun SimpleMessageContainer(message: String, timestamp: String, fontSize: TextUnit, lightBackgroundColor: Color, darkBackgroundColor: Color, textAlpha: Float, modifier: Modifier = Modifier) { val bgColor = rememberBackgroundColor(lightBackgroundColor, darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) val linkColor = MaterialTheme.colorScheme.primary @@ -63,7 +55,7 @@ fun SimpleMessageContainer( .wrapContentHeight() .alpha(textAlpha) .background(bgColor) - .padding(horizontal = 2.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { ClickableText( text = annotatedString, @@ -74,7 +66,7 @@ fun SimpleMessageContainer( .firstOrNull()?.let { annotation -> launchCustomTab(context, annotation.item) } - } + }, ) } } 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 index d7c874701..2b870519b 100644 --- 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 @@ -19,7 +19,7 @@ import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator /** * 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 @@ -46,7 +46,7 @@ fun RepliesComposable( CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { when (uiState) { - is RepliesUiState.Found -> { + is RepliesUiState.Found -> { ChatScreen( messages = (uiState as RepliesUiState.Found).items, fontSize = displaySettings.fontSize, 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 index a2a064976..6081d63fa 100644 --- 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 @@ -7,11 +7,13 @@ import com.flxrs.dankchat.ui.chat.ChatMessageUiState @Immutable sealed interface RepliesState { data object NotFound : RepliesState + data class Found(val items: List) : RepliesState } @Immutable sealed interface RepliesUiState { data object NotFound : RepliesUiState + data class Found(val items: List) : 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 index fed13f1d9..297afd3cf 100644 --- 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 @@ -28,47 +28,53 @@ class RepliesViewModel( private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + showLineSeparator = appearance.lineSeparator, + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) - val chatDisplaySettings: StateFlow = combine( - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings, - ) { appearance, chat -> - ChatDisplaySettings( - fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, - animateGifs = chat.animateGifs, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + val state = + repliesRepository + .getThreadItemsFlow(rootMessageId) + .map { + when { + it.isEmpty() -> RepliesState.NotFound + else -> RepliesState.Found(it) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(emptyList())) - val state = repliesRepository.getThreadItemsFlow(rootMessageId) - .map { - when { - it.isEmpty() -> RepliesState.NotFound - else -> RepliesState.Found(it) - } - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(emptyList())) + val uiState: StateFlow = + combine( + state, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { repliesState, appearanceSettings, chatSettings -> + when (repliesState) { + is RepliesState.NotFound -> { + RepliesUiState.NotFound + } - val uiState: StateFlow = combine( - state, - appearanceSettingsDataStore.settings, - chatSettingsDataStore.settings - ) { repliesState, appearanceSettings, chatSettings -> - when (repliesState) { - is RepliesState.NotFound -> RepliesUiState.NotFound - is RepliesState.Found -> { - val uiMessages = repliesState.items.mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg - ) + is RepliesState.Found -> { + val uiMessages = + repliesState.items.mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + } + RepliesUiState.Found(uiMessages) } - RepliesUiState.Found(uiMessages) } - } - }.flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(emptyList())) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(emptyList())) } 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 index 7eae4e51b..8f68cb76c 100644 --- 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 @@ -5,70 +5,68 @@ 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) - } + 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 { - return 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 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 { - return when (val message = item.message) { - is PrivMessage -> { - message.name.value.equals(name, ignoreCase = true) || - message.displayName.value.equals(name, ignoreCase = true) - } + 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) + } - else -> false + else -> { + false } } - private fun matchLink(item: ChatItem): Boolean { - return when (val message = item.message) { - is PrivMessage -> URL_REGEX.containsMatchIn(message.message) - 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 { - return when (val message = item.message) { - is PrivMessage -> { - when (emoteName) { - null -> message.emotes.isNotEmpty() - else -> message.emotes.any { it.code.equals(emoteName, ignoreCase = true) } - } + 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 + else -> { + false } } - private fun matchBadge(item: ChatItem, badgeName: String): Boolean { - return when (val message = item.message) { - is PrivMessage -> message.badges.any { badge -> + 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 + badge.title?.contains(badgeName, ignoreCase = true) == true } + } - else -> false + 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 index 8ca9d8689..806327fab 100644 --- 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 @@ -7,8 +7,12 @@ 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 index e946c8217..114efb37d 100644 --- 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 @@ -1,7 +1,6 @@ package com.flxrs.dankchat.ui.chat.search object ChatSearchFilterParser { - fun parse(query: String): List { if (query.isBlank()) return emptyList() @@ -25,24 +24,35 @@ object ChatSearchFilterParser { val value = raw.substring(colonIndex + 1) when (prefix) { - "from" -> return when { + "from" -> return when { isBeingTyped || value.isEmpty() -> null - else -> ChatSearchFilter.Author(name = value, negate = negate) + 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) + "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) + else -> ChatSearchFilter.BadgeFilter(badgeName = value.lowercase(), negate = negate) } } } @@ -50,10 +60,8 @@ object ChatSearchFilterParser { return ChatSearchFilter.Text(query = raw, negate = negate) } - private fun extractNegation(token: String): Pair { - return when { - token.startsWith('!') || token.startsWith('-') -> true to token.substring(1) - else -> false to token - } + 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 index 14d43bfe3..6318733c5 100644 --- 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 @@ -5,28 +5,30 @@ 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 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 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("!") + 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) { @@ -41,22 +43,30 @@ object SearchFilterSuggestions { 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) + "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) } } - "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) } + "has:" -> { + HAS_VALUES.filter { suggestion -> + val value = suggestion.displayText.orEmpty() + value.startsWith(partial, ignoreCase = true) && !value.equals(partial, ignoreCase = true) + } + } - else -> emptyList() + "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/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt index ad17545ef..ad626ba4b 100644 --- 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 @@ -22,12 +22,7 @@ class SuggestionProvider( private val emoteUsageRepository: EmoteUsageRepository, private val emojiRepository: EmojiRepository, ) { - - fun getSuggestions( - inputText: String, - cursorPosition: Int, - channel: UserName? - ): Flow> { + fun getSuggestions(inputText: String, cursorPosition: Int, channel: UserName?): Flow> { if (inputText.isBlank() || channel == null) { return flowOf(emptyList()) } @@ -39,10 +34,11 @@ class SuggestionProvider( // ':' trigger: emote + emoji mode with reduced min chars val isEmoteTrigger = currentWord.startsWith(':') - val emoteQuery = when { - isEmoteTrigger -> currentWord.removePrefix(":") - else -> currentWord - } + val emoteQuery = + when { + isEmoteTrigger -> currentWord.removePrefix(":") + else -> currentWord + } if (isEmoteTrigger && emoteQuery.isEmpty()) { return flowOf(emptyList()) @@ -67,33 +63,25 @@ class SuggestionProvider( } } - private fun getEmoteSuggestions(channel: UserName, constraint: String): Flow> { - return emoteRepository.getEmotes(channel).map { emotes -> - val recentIds = emoteUsageRepository.recentEmoteIds.value - filterEmotes(emotes.suggestions, constraint, recentIds) - } + private fun getEmoteSuggestions(channel: UserName, constraint: String): Flow> = emoteRepository.getEmotes(channel).map { emotes -> + val recentIds = emoteUsageRepository.recentEmoteIds.value + filterEmotes(emotes.suggestions, constraint, recentIds) } - private fun getScoredEmoteSuggestions(channel: UserName, constraint: String): Flow> { - return emoteRepository.getEmotes(channel).map { emotes -> - val recentIds = emoteUsageRepository.recentEmoteIds.value - filterEmotesScored(emotes.suggestions, constraint, recentIds) - } + 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 getUserSuggestions(channel: UserName, constraint: String): Flow> { - return usersRepository.getUsersFlow(channel).map { displayNameSet -> - filterUsers(displayNameSet, constraint) - } + private fun getUserSuggestions(channel: UserName, constraint: String): Flow> = usersRepository.getUsersFlow(channel).map { displayNameSet -> + filterUsers(displayNameSet, constraint) } - private fun getCommandSuggestions(channel: UserName, constraint: String): Flow> { - return combine( - commandRepository.getCommandTriggers(channel), - commandRepository.getSupibotCommands(channel) - ) { triggers, supibotCommands -> - filterCommands(triggers + supibotCommands, constraint) - } + private fun getCommandSuggestions(channel: UserName, constraint: String): Flow> = combine( + commandRepository.getCommandTriggers(channel), + commandRepository.getSupibotCommands(channel), + ) { triggers, supibotCommands -> + filterCommands(triggers + supibotCommands, constraint) } // Merge two pre-sorted lists in O(n+m) without intermediate allocations @@ -102,12 +90,13 @@ class SuggestionProvider( 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++] - } + 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 @@ -141,64 +130,37 @@ class SuggestionProvider( } // Score raw GenericEmotes, only wrap matches - internal fun filterEmotes( - emotes: List, - constraint: String, - recentEmoteIds: Set, - ): List { - return filterEmotesScored(emotes, constraint, recentEmoteIds).map { it.suggestion as Suggestion.EmoteSuggestion } - } + internal fun filterEmotes(emotes: List, constraint: String, recentEmoteIds: Set): List = + filterEmotesScored(emotes, constraint, recentEmoteIds).map { it.suggestion as Suggestion.EmoteSuggestion } - private fun filterEmotesScored( - emotes: List, - constraint: String, - recentEmoteIds: Set, - ): List { - return 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 } - } + private 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 { - return 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 } - } + 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 - internal fun filterUsers( - users: Set, - constraint: String, - ): List { + 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 }) + }.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.value }) } // Filter raw command strings, only wrap matches - internal fun filterCommands( - commands: List, - constraint: String, - ): List { - return commands - .filter { it.startsWith(constraint, ignoreCase = true) } - .sortedWith(String.CASE_INSENSITIVE_ORDER) - .map { Suggestion.CommandSuggestion(it) } - } + 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 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 index 4602b2d36..e1f5bca6e 100644 --- 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 @@ -88,10 +88,10 @@ fun UserPopupDialog( transitionSpec = { when { targetState -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() } }, - label = "UserPopupContent" + label = "UserPopupContent", ) { isBlockConfirmation -> when { isBlockConfirmation -> { @@ -130,22 +130,22 @@ fun UserPopupDialog( } } - else -> { + else -> { Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally + 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) + modifier = Modifier.padding(horizontal = 16.dp), ) } - else -> { + else -> { val userName = state.userName val displayName = state.displayName val isSuccess = state is UserPopupState.Success @@ -166,7 +166,7 @@ fun UserPopupDialog( onMention(userName.value, displayName.value) onDismiss() }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent) + colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) if (!isOwnUser) { ListItem( @@ -176,7 +176,7 @@ fun UserPopupDialog( onWhisper(userName.value) onDismiss() }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent) + colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } if (onMessageHistory != null) { @@ -187,7 +187,7 @@ fun UserPopupDialog( onMessageHistory(userName.value) onDismiss() }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent) + colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } if (isSuccess && !isOwnUser) { @@ -201,7 +201,7 @@ fun UserPopupDialog( showBlockConfirmation = true } }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent) + colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } if (!isOwnUser) { @@ -212,7 +212,7 @@ fun UserPopupDialog( onReport(userName.value) onDismiss() }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent) + colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } } @@ -226,18 +226,12 @@ fun UserPopupDialog( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun UserInfoSection( - state: UserPopupState, - userName: UserName, - displayName: DisplayName, - badges: List, - onOpenChannel: (String) -> Unit, -) { +private fun UserInfoSection(state: UserPopupState, userName: UserName, displayName: DisplayName, badges: List, onOpenChannel: (String) -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { when (state) { is UserPopupState.Success -> { @@ -247,20 +241,20 @@ private fun UserInfoSection( modifier = Modifier .size(96.dp) .clip(CircleShape) - .clickable { onOpenChannel(state.userName.value) } + .clickable { onOpenChannel(state.userName.value) }, ) } is UserPopupState.Loading -> { Box( modifier = Modifier.size(96.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } - is UserPopupState.Error -> {} + is UserPopupState.Error -> {} } Spacer(modifier = Modifier.width(16.dp)) @@ -269,14 +263,14 @@ private fun UserInfoSection( text = userName.formatWithDisplayName(displayName), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center + 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 + textAlign = TextAlign.Center, ) if (state.showFollowingSince) { Text( @@ -284,7 +278,7 @@ private fun UserInfoSection( stringResource(R.string.user_popup_following_since, it) } ?: stringResource(R.string.user_popup_not_following), style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } if (badges.isNotEmpty()) { @@ -304,14 +298,14 @@ private fun UserInfoSection( AsyncImage( model = badge.url, contentDescription = title, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) } } else { AsyncImage( model = badge.url, contentDescription = null, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) } } @@ -319,7 +313,7 @@ private fun UserInfoSection( } } - else -> {} + else -> {} } } } @@ -329,12 +323,12 @@ private val UserPopupState.userName: UserName get() = when (this) { is UserPopupState.Loading -> userName is UserPopupState.Success -> userName - is UserPopupState.Error -> 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("") + is UserPopupState.Error -> DisplayName("") } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt index 043f9677b..2e00f81eb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt @@ -8,7 +8,9 @@ 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 Success( val userId: UserId, val userName: UserName, @@ -17,6 +19,6 @@ sealed interface UserPopupState { val avatarUrl: String, val showFollowingSince: Boolean = false, val followingSince: String? = null, - val isBlocked: Boolean = false + val isBlocked: Boolean = false, ) : UserPopupState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt index 7b2f31014..4723103bf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt @@ -29,7 +29,6 @@ class UserPopupViewModel( private val userStateRepository: UserStateRepository, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { - private val _userPopupState = MutableStateFlow(UserPopupState.Loading(params.targetUserName, params.targetDisplayName)) val userPopupState: StateFlow = _userPopupState.asStateFlow() val isOwnUser: Boolean get() = preferenceStore.userIdString == params.targetUserId @@ -54,7 +53,7 @@ class UserPopupViewModel( val result = runCatching { block(params.targetUserId, params.targetUserName) } when { result.isFailure -> _userPopupState.value = UserPopupState.Error(result.exceptionOrNull()) - else -> loadData() + else -> loadData() } } @@ -67,25 +66,28 @@ class UserPopupViewModel( } 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 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) - } + 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 @@ -101,8 +103,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, ) } } 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 index d81ca7cdb..de09e8c60 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt @@ -38,10 +38,7 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LoginScreen( - onLoginSuccess: () -> Unit, - onCancel: () -> Unit, -) { +fun LoginScreen(onLoginSuccess: () -> Unit, onCancel: () -> Unit) { val viewModel: LoginViewModel = koinViewModel() var isLoading by remember { mutableStateOf(true) } var isZoomedOut by remember { mutableStateOf(false) } @@ -77,12 +74,12 @@ fun LoginScreen( } }, ) - } + }, ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) - .fillMaxSize() + .fillMaxSize(), ) { if (isLoading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) @@ -93,7 +90,7 @@ fun LoginScreen( WebView(context).also { webViewRef = it }.apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + ViewGroup.LayoutParams.MATCH_PARENT, ) @SuppressLint("SetJavaScriptEnabled") settings.javaScriptEnabled = true @@ -126,7 +123,7 @@ fun LoginScreen( loadUrl(viewModel.loginUrl) } }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) } } 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 index edb2312e0..771675d08 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt @@ -12,11 +12,7 @@ import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @KoinViewModel -class LoginViewModel( - private val authApiClient: AuthApiClient, - private val authDataStore: AuthDataStore, -) : ViewModel() { - +class LoginViewModel(private val authApiClient: AuthApiClient, private val authDataStore: AuthDataStore) : ViewModel() { data class TokenParseEvent(val successful: Boolean) private val eventChannel = Channel(Channel.BUFFERED) @@ -30,17 +26,19 @@ class LoginViewModel( 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) - } - ) + 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) } 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 index 1b1dbf249..d4ee67936 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt @@ -15,10 +15,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp @Composable -fun DraggableHandle( - onDrag: (deltaPx: Float) -> Unit, - modifier: Modifier = Modifier, -) { +fun DraggableHandle(onDrag: (deltaPx: Float) -> Unit, modifier: Modifier = Modifier) { Box( contentAlignment = Alignment.Center, modifier = modifier @@ -28,7 +25,7 @@ fun DraggableHandle( detectHorizontalDragGestures { _, dragAmount -> onDrag(dragAmount) } - } + }, ) { Box( modifier = Modifier @@ -36,9 +33,9 @@ fun DraggableHandle( .height(56.dp) .background( color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Box( modifier = Modifier @@ -46,8 +43,8 @@ fun DraggableHandle( .height(40.dp) .background( color = MaterialTheme.colorScheme.onSurfaceVariant, - shape = RoundedCornerShape(2.dp) - ) + 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 index c8af2cc85..1ed1d8b16 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt @@ -26,12 +26,7 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R @Composable -fun EmptyStateContent( - isLoggedIn: Boolean, - onAddChannel: () -> Unit, - onLogin: () -> Unit, - modifier: Modifier = Modifier, -) { +fun EmptyStateContent(isLoggedIn: Boolean, onAddChannel: () -> Unit, onLogin: () -> Unit, modifier: Modifier = Modifier) { Surface(modifier = modifier) { Column( modifier = Modifier 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 index 3869f3450..17fae6ff8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -106,7 +106,6 @@ import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first import kotlin.coroutines.cancellation.CancellationException - @Suppress("MultipleEmitters") @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -129,7 +128,6 @@ fun FloatingToolbar( keyboardHeightDp: Dp = 0.dp, streamToolbarAlpha: Float = 1f, ) { - val density = LocalDensity.current var showOverflowMenu by remember { mutableStateOf(false) } var showQuickSwitch by remember { mutableStateOf(false) } @@ -162,12 +160,12 @@ fun FloatingToolbar( .fillMaxSize() .clickable( indication = null, - interactionSource = remember { MutableInteractionSource() } + interactionSource = remember { MutableInteractionSource() }, ) { showOverflowMenu = false showQuickSwitch = false overflowInitialMenu = AppBarMenu.Main - } + }, ) } @@ -180,7 +178,7 @@ fun FloatingToolbar( modifier = modifier .fillMaxWidth() .padding(top = if (hasStream) streamHeightDp + 8.dp else 0.dp) - .graphicsLayer { alpha = streamToolbarAlpha } + .graphicsLayer { alpha = streamToolbarAlpha }, ) { val scrimColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) val statusBarPx = with(density) { WindowInsets.statusBars.getTop(density).toFloat() } @@ -198,9 +196,9 @@ fun FloatingToolbar( 0f to scrimColor, 0.75f to scrimColor, 1f to scrimColor.copy(alpha = 0f), - endY = gradientHeight + endY = gradientHeight, ), - size = Size(size.width, gradientHeight) + size = Size(size.width, gradientHeight), ) } } @@ -252,7 +250,7 @@ fun FloatingToolbar( val h = it.height.toFloat() if (toolbarRowHeight == 0f || h < toolbarRowHeight) toolbarRowHeight = h }, - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { // Push action pill to end when no tabs are shown if (endAligned && (!showTabs || tabState.tabs.isEmpty())) { @@ -281,11 +279,11 @@ fun FloatingToolbar( brush = Brush.horizontalGradient( colors = listOf( mentionGradientColor.copy(alpha = 0.5f), - mentionGradientColor.copy(alpha = 0f) + mentionGradientColor.copy(alpha = 0f), ), - endX = gradientWidth + endX = gradientWidth, ), - size = Size(gradientWidth, size.height) + size = Size(gradientWidth, size.height), ) } if (hasRightMention) { @@ -293,13 +291,13 @@ fun FloatingToolbar( brush = Brush.horizontalGradient( colors = listOf( mentionGradientColor.copy(alpha = 0f), - mentionGradientColor.copy(alpha = 0.5f) + mentionGradientColor.copy(alpha = 0.5f), ), startX = size.width - gradientWidth, - endX = size.width + endX = size.width, ), topLeft = Offset(size.width - gradientWidth, 0f), - size = Size(gradientWidth, size.height) + size = Size(gradientWidth, size.height), ) } }, @@ -312,7 +310,7 @@ fun FloatingToolbar( .padding(horizontal = 12.dp) .onSizeChanged { tabViewportWidth = it.width } .clipToBounds() - .horizontalScroll(tabScrollState) + .horizontalScroll(tabScrollState), ) { tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex @@ -331,7 +329,7 @@ fun FloatingToolbar( onAction(ToolbarAction.LongClickTab(index)) overflowInitialMenu = AppBarMenu.Main showOverflowMenu = true - } + }, ) .defaultMinSize(minHeight = 48.dp) .padding(horizontal = 12.dp) @@ -344,7 +342,7 @@ fun FloatingToolbar( } tabOffsets.value[index] = coords.positionInParent().x.toInt() tabWidths.value[index] = coords.size.width - } + }, ) { Text( text = tab.displayName, @@ -386,7 +384,7 @@ fun FloatingToolbar( ) drawRect(color = pillColor.copy(alpha = 0.6f)) }, - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Default.ArrowDropDown, @@ -438,13 +436,13 @@ fun FloatingToolbar( modifier = Modifier .width(IntrinsicSize.Min) .widthIn(min = 125.dp, max = 200.dp) - .heightIn(max = maxMenuHeight) + .heightIn(max = maxMenuHeight), ) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(quickSwitchScrollState) - .padding(vertical = 8.dp) + .padding(vertical = 8.dp), ) { tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex @@ -464,7 +462,7 @@ fun FloatingToolbar( style = MaterialTheme.typography.bodyLarge, color = when { isSelected -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface }, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, maxLines = 1, @@ -483,13 +481,13 @@ fun FloatingToolbar( .align(Alignment.TopEnd) .fillMaxHeight() .width(3.dp) - .padding(vertical = 2.dp) + .padding(vertical = 2.dp), ) { Thumb( Modifier.background( MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), - RoundedCornerShape(100) - ) + RoundedCornerShape(100), + ), ) } } @@ -518,7 +516,7 @@ fun FloatingToolbar( IconButton(onClick = { onAction(ToolbarAction.AddChannel) }) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_channel) + contentDescription = stringResource(R.string.add_channel), ) } } @@ -528,8 +526,8 @@ fun FloatingToolbar( } LaunchedEffect(Unit) { snapshotFlow { addChannelTooltipState.isVisible } - .dropWhile { !it } // skip initial false - .first { !it } // wait for dismiss (any cause) + .dropWhile { !it } // skip initial false + .first { !it } // wait for dismiss (any cause) onAddChannelTooltipDismiss() } TooltipBox( @@ -549,14 +547,21 @@ fun FloatingToolbar( caretShape = TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)), action = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismiss(); onSkipTour() }) { + TextButton(onClick = { + addChannelTooltipState.dismiss() + onAddChannelTooltipDismiss() + onSkipTour() + }) { Text(stringResource(R.string.tour_skip)) } - TextButton(onClick = { addChannelTooltipState.dismiss(); onAddChannelTooltipDismiss() }) { + TextButton(onClick = { + addChannelTooltipState.dismiss() + onAddChannelTooltipDismiss() + }) { Text(stringResource(R.string.tour_next)) } } - } + }, ) { Text(stringResource(R.string.tour_add_more_channels_hint)) } @@ -578,7 +583,7 @@ fun FloatingToolbar( MaterialTheme.colorScheme.error } else { LocalContentColor.current - } + }, ) } } @@ -589,7 +594,7 @@ fun FloatingToolbar( }) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more) + contentDescription = stringResource(R.string.more), ) } } @@ -638,7 +643,7 @@ private fun Modifier.endAlignedOverflow() = this.then( 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)) + constraints.copy(minWidth = 0, maxWidth = (parentWidth * 3).coerceAtMost(MAX_LAYOUT_SIZE)), ) return layout(parentWidth, placeable.height) { placeable.place(parentWidth - placeable.width, 0) @@ -647,12 +652,10 @@ private fun Modifier.endAlignedOverflow() = this.then( 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.minIntrinsicHeight(measurable: IntrinsicMeasurable, width: Int): Int = measurable.minIntrinsicHeight(width) - override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurable: IntrinsicMeasurable, width: Int): Int = - measurable.maxIntrinsicHeight(width) - } + override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurable: IntrinsicMeasurable, width: Int): Int = measurable.maxIntrinsicHeight(width) + }, ) /** @@ -671,13 +674,10 @@ private fun Modifier.skipIntrinsicHeight() = this.then( 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.minIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int = measurable.minIntrinsicWidth(height) - override fun IntrinsicMeasureScope.maxIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int = - measurable.maxIntrinsicWidth(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 index 4e5d0d75f..1b27a1898 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt @@ -5,9 +5,14 @@ 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 index 8d6e9caba..d1b81c873 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -86,7 +86,6 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.IOException class MainActivity : AppCompatActivity() { - private val viewModel: DankChatViewModel by viewModel() private val dankChatPreferenceStore: DankChatPreferenceStore by inject() private val mainEventBus: MainEventBus by inject() @@ -95,40 +94,44 @@ class MainActivity : AppCompatActivity() { private val pendingChannelsToClear = mutableListOf() 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 requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + startService() + } - private val requestVideoCapture = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) handleCaptureRequest(imageCapture = false) - } + private val requestImageCapture = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) handleCaptureRequest(imageCapture = true) + } - 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 + private val requestVideoCapture = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) handleCaptureRequest(imageCapture = false) } - 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() + 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)) } } - uploadMedia(copy, imageCapture = false) - } catch (_: Throwable) { - copy.delete() - lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.UploadFailed(null, copy, false)) } } - } private val twitchServiceConnection = TwitchServiceConnection() var notificationService: NotificationService? = null @@ -139,21 +142,33 @@ class MainActivity : AppCompatActivity() { val isDynamicColorAvailable = DynamicColors.isDynamicColorAvailable() when { isTrueDarkModeEnabled && isDynamicColorAvailable -> { - val dynamicColorsOptions = DynamicColorsOptions.Builder() - .setThemeOverlay(R.style.AppTheme_TrueDarkOverlay) - .build() + 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) + window + .peekDecorView() + ?.context + ?.theme + ?.applyStyle(R.style.AppTheme_TrueDarkOverlay, true) } - isTrueDarkModeEnabled -> { + isTrueDarkModeEnabled -> { theme.applyStyle(R.style.AppTheme_TrueDarkTheme, true) - window.peekDecorView()?.context?.theme?.applyStyle(R.style.AppTheme_TrueDarkTheme, true) + window + .peekDecorView() + ?.context + ?.theme + ?.applyStyle(R.style.AppTheme_TrueDarkTheme, true) } - else -> DynamicColors.applyToActivityIfAvailable(this) + else -> { + DynamicColors.applyToActivityIfAvailable(this) + } } enableEdgeToEdge() @@ -174,16 +189,14 @@ class MainActivity : AppCompatActivity() { when (it) { ServiceEvent.Shutdown -> handleShutDown() } - } - .launchIn(lifecycleScope) + }.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) + }.launchIn(lifecycleScope) } private fun setupComposeUi() { @@ -191,7 +204,7 @@ class MainActivity : AppCompatActivity() { DankChatTheme { val navController = rememberNavController() val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle( - initialValue = dankChatPreferenceStore.isLoggedIn + initialValue = dankChatPreferenceStore.isLoggedIn, ) val onboardingCompleted = onboardingDataStore.current().hasCompletedOnboarding @@ -204,7 +217,7 @@ class MainActivity : AppCompatActivity() { enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, exitTransition = { fadeOut(animationSpec = tween(90)) }, popEnterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, - popExitTransition = { fadeOut(animationSpec = tween(90)) } + popExitTransition = { fadeOut(animationSpec = tween(90)) }, ) { OnboardingScreen( onNavigateToLogin = { @@ -265,18 +278,18 @@ class MainActivity : AppCompatActivity() { }, onChooseMedia = { requestGalleryMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) - } + }, ) } 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)) } + popExitTransition = { fadeOut(animationSpec = tween(90)) }, ) { LoginScreen( onLoginSuccess = { navController.popBackStack() }, - onCancel = { navController.popBackStack() } + onCancel = { navController.popBackStack() }, ) } composable( @@ -306,7 +319,7 @@ class MainActivity : AppCompatActivity() { SettingsNavigation.Changelog -> navController.navigate(ChangelogSettings) SettingsNavigation.About -> navController.navigate(AboutSettings) } - } + }, ) } @@ -327,146 +340,146 @@ class MainActivity : AppCompatActivity() { enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { AppearanceSettingsScreen( - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { NotificationsSettingsScreen( onNavToHighlights = { navController.navigate(HighlightsSettings) }, onNavToIgnores = { navController.navigate(IgnoresSettings) }, - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { HighlightsScreen( - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { IgnoresScreen( - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { ChatSettingsScreen( onNavToCommands = { navController.navigate(CustomCommandsSettings) }, onNavToUserDisplays = { navController.navigate(UserDisplaySettings) }, - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { CustomCommandsScreen( - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { UserDisplayScreen( - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { StreamsSettingsScreen( - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { ToolsSettingsScreen( onNavToImageUploader = { navController.navigate(ImageUploaderSettings) }, onNavToTTSUserIgnoreList = { navController.navigate(TTSUserIgnoreListSettings) }, - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { ImageUploaderScreen( - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { TTSUserIgnoreListScreen( - onNavBack = { navController.popBackStack() } + onNavBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { DeveloperSettingsScreen( - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { ChangelogScreen( - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( enterTransition = subEnter, exitTransition = subExit, popEnterTransition = subPopEnter, - popExitTransition = subPopExit + popExitTransition = subPopExit, ) { AboutScreen( - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } } @@ -490,18 +503,20 @@ class MainActivity : AppCompatActivity() { val needsNotificationPermission = hasCompletedOnboarding && isAtLeastTiramisu && !hasPermission(Manifest.permission.POST_NOTIFICATIONS) when { needsNotificationPermission -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - else -> startService() + 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) { - Log.e(TAG, Log.getStackTraceString(t)) + if (!isBound) { + Intent(this, NotificationService::class.java).also { + try { + isBound = true + ContextCompat.startForegroundService(this, it) + bindService(it, twitchServiceConnection, BIND_AUTO_CREATE) + } catch (t: Throwable) { + Log.e(TAG, Log.getStackTraceString(t)) + } } } } @@ -535,7 +550,7 @@ class MainActivity : AppCompatActivity() { fun clearNotificationsOfChannel(channel: UserName) = when { isBound && notificationService != null -> notificationService?.setActiveChannel(channel) - else -> pendingChannelsToClear += channel + else -> pendingChannelsToClear += channel } private fun handleShutDown() { @@ -545,10 +560,11 @@ class MainActivity : AppCompatActivity() { } private fun startCameraCapture(captureVideo: Boolean = false) { - val (action, extension) = when { - captureVideo -> MediaStore.ACTION_VIDEO_CAPTURE to "mp4" - else -> MediaStore.ACTION_IMAGE_CAPTURE to "jpg" - } + 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 { @@ -560,7 +576,7 @@ class MainActivity : AppCompatActivity() { captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri) when { captureVideo -> requestVideoCapture.launch(captureIntent) - else -> requestImageCapture.launch(captureIntent) + else -> requestImageCapture.launch(captureIntent) } } } @@ -597,12 +613,13 @@ class MainActivity : AppCompatActivity() { mainEventBus.emitEvent(MainEvent.UploadSuccess(url)) }, onFailure = { throwable -> - val message = when (throwable) { - is ApiException -> "${throwable.status} ${throwable.message}" - else -> throwable.message - } + val message = + when (throwable) { + is ApiException -> "${throwable.status} ${throwable.message}" + else -> throwable.message + } mainEventBus.emitEvent(MainEvent.UploadFailed(message, file, imageCapture)) - } + }, ) } } 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 index 7530339f8..477bbf9b0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -78,13 +78,7 @@ sealed interface AppBarMenu { } @Composable -fun InlineOverflowMenu( - isLoggedIn: Boolean, - onDismiss: () -> Unit, - onAction: (ToolbarAction) -> Unit, - initialMenu: AppBarMenu = AppBarMenu.Main, - keyboardHeightDp: Dp = 0.dp, -) { +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) } @@ -95,7 +89,8 @@ fun InlineOverflowMenu( } when (currentMenu) { AppBarMenu.Main -> onDismiss() - else -> { + + else -> { backProgress = 0f currentMenu = AppBarMenu.Main } @@ -145,20 +140,41 @@ fun InlineOverflowMenu( .padding(vertical = 8.dp), ) { when (menu) { - AppBarMenu.Main -> { + AppBarMenu.Main -> { if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { onAction(ToolbarAction.Login); onDismiss() } + InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { + onAction(ToolbarAction.Login) + onDismiss() + } } else { - InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { onAction(ToolbarAction.Relogin); onDismiss() } - InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { onAction(ToolbarAction.Logout); onDismiss() } + InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { + onAction(ToolbarAction.Relogin) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { + onAction(ToolbarAction.Logout) + onDismiss() + } } HorizontalDivider() - InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { onAction(ToolbarAction.ManageChannels); onDismiss() } - InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { onAction(ToolbarAction.RemoveChannel); onDismiss() } - InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { onAction(ToolbarAction.ReloadEmotes); onDismiss() } - InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { onAction(ToolbarAction.Reconnect); onDismiss() } + InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { + onAction(ToolbarAction.ManageChannels) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { + onAction(ToolbarAction.RemoveChannel) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { + onAction(ToolbarAction.ReloadEmotes) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { + onAction(ToolbarAction.Reconnect) + onDismiss() + } HorizontalDivider() @@ -167,22 +183,43 @@ fun InlineOverflowMenu( HorizontalDivider() - InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { onAction(ToolbarAction.OpenSettings); onDismiss() } + InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { + onAction(ToolbarAction.OpenSettings) + onDismiss() + } } - AppBarMenu.Upload -> { + AppBarMenu.Upload -> { InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { onAction(ToolbarAction.CaptureImage); onDismiss() } - InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { onAction(ToolbarAction.CaptureVideo); onDismiss() } - InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { onAction(ToolbarAction.ChooseMedia); onDismiss() } + InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { + onAction(ToolbarAction.CaptureImage) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { + onAction(ToolbarAction.CaptureVideo) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { + onAction(ToolbarAction.ChooseMedia) + onDismiss() + } } AppBarMenu.Channel -> { InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { onAction(ToolbarAction.OpenChannel); onDismiss() } - InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { onAction(ToolbarAction.ReportChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { + onAction(ToolbarAction.OpenChannel) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { + onAction(ToolbarAction.ReportChannel) + onDismiss() + } if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { onAction(ToolbarAction.BlockChannel); onDismiss() } + InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { + onAction(ToolbarAction.BlockChannel) + onDismiss() + } } } } @@ -194,13 +231,13 @@ fun InlineOverflowMenu( .align(Alignment.TopEnd) .fillMaxHeight() .width(3.dp) - .padding(vertical = 2.dp) + .padding(vertical = 2.dp), ) { Thumb( Modifier.background( MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), - RoundedCornerShape(100) - ) + RoundedCornerShape(100), + ), ) } } @@ -229,7 +266,7 @@ private fun InlineMenuItem(text: String, icon: ImageVector, hasSubMenu: Boolean color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) if (hasSubMenu) { Icon( @@ -249,18 +286,18 @@ private fun InlineSubMenuHeader(title: String, onBack: () -> Unit) { .fillMaxWidth() .clickable(onClick = onBack) .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = 8.dp) + modifier = Modifier.padding(end = 8.dp), ) Text( text = title, style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } } 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 index aa0a2480e..9b83003c8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt @@ -5,13 +5,22 @@ 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 LoginValidated(val username: UserName) : MainEvent + data class LoginOutdated(val username: UserName) : MainEvent + data object LoginTokenInvalid : MainEvent + data object LoginValidationFailed : MainEvent + data class OpenChannel(val channel: UserName) : MainEvent } 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 index e986d9ab1..a9e91bbde 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -458,7 +458,9 @@ fun MainScreen( onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, onNewWhisper = if (inputState.isWhisperTabActive) { dialogViewModel::showNewWhisper - } else null, + } else { + null + }, onRepeatedSendChange = chatInputViewModel::setRepeatedSend, ), isUploading = dialogState.isUploading, @@ -470,16 +472,17 @@ fun MainScreen( isSheetOpen = isSheetOpen, inputActions = when (fullScreenSheetState) { is FullScreenSheetState.Replies -> persistentListOf(InputAction.LastMessage) + is FullScreenSheetState.Whisper, is FullScreenSheetState.Mention, - -> when { + -> when { inputState.isWhisperTabActive && inputState.overlay is InputOverlay.Whisper -> persistentListOf(InputAction.LastMessage) else -> persistentListOf() } is FullScreenSheetState.History, is FullScreenSheetState.Closed, - -> mainState.inputActions + -> mainState.inputActions }, onInputHeightChange = { inputHeightPx = it }, debugMode = mainState.debugMode, @@ -496,8 +499,8 @@ fun MainScreen( 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, + isTourActive = featureTourState.isTourActive || + featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint, onAdvance = featureTourViewModel::advance, onSkip = featureTourViewModel::skipTour, ) @@ -529,13 +532,21 @@ fun MainScreen( } 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) } @@ -862,12 +873,14 @@ fun MainScreen( } // Floating Toolbars - collapsible tabs (expand on swipe) + actions - if (!isInPipMode) floatingToolbar( - Modifier.align(Alignment.TopCenter), - (!isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen)) && !isSheetOpen, - true, - true, - ) + if (!isInPipMode) { + floatingToolbar( + Modifier.align(Alignment.TopCenter), + (!isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen)) && !isSheetOpen, + true, + true, + ) + } // Status bar scrim when toolbar is gesture-hidden — keeps status bar readable if (!isInPipMode && !isFullscreen && mainState.gestureToolbarHidden) { 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 index 5d7194ffa..d5999d43d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -76,7 +76,7 @@ internal fun observePipMode(streamViewModel: StreamViewModel): Boolean { PictureInPictureParams.Builder() .setAutoEnterEnabled(enabled) .setAspectRatio(Rational(16, 9)) - .build() + .build(), ) } } @@ -116,16 +116,13 @@ internal fun FullscreenSystemBarsEffect(isFullscreen: Boolean) { * Additional graphicsLayer transforms (e.g. fade with stream) can be applied via [modifier]. */ @Composable -internal fun StatusBarScrim( - modifier: Modifier = Modifier, - colorAlpha: Float = 0.7f, -) { +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)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = colorAlpha)), ) } @@ -133,10 +130,7 @@ internal fun StatusBarScrim( * Fullscreen scrim that dismisses the input overflow menu when tapped. */ @Composable -internal fun InputDismissScrim( - forceOpen: Boolean, - onDismiss: () -> Unit, -) { +internal fun InputDismissScrim(forceOpen: Boolean, onDismiss: () -> Unit) { Box( modifier = Modifier .fillMaxSize() @@ -147,7 +141,7 @@ internal fun InputDismissScrim( if (!forceOpen) { onDismiss() } - } + }, ) } @@ -179,14 +173,14 @@ internal fun BoxScope.EdgeGestureGuards() { modifier = Modifier .align(AbsoluteAlignment.CenterLeft) .width(with(density) { systemGestureInsets.getLeft(density, layoutDirection).toDp() }) - .then(edgeGuardModifier) + .then(edgeGuardModifier), ) // Right edge guard Box( modifier = Modifier .align(AbsoluteAlignment.CenterRight) .width(with(density) { systemGestureInsets.getRight(density, layoutDirection).toDp() }) - .then(edgeGuardModifier) + .then(edgeGuardModifier), ) } @@ -195,19 +189,12 @@ internal fun BoxScope.EdgeGestureGuards() { * 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, -) { +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 + modifier = modifier, ) { Box( modifier = Modifier @@ -220,14 +207,13 @@ internal fun EmoteMenuOverlay( alpha = 1f - backProgress translationY = backProgress * 100f } - .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), ) { EmoteMenu( onEmoteClick = onEmoteClick, onBackspace = onBackspace, - modifier = Modifier.fillMaxSize() + 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 index b801347b0..d5620dcf1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -50,8 +50,10 @@ fun MainScreenEventHandler( mainEventBus.events.collect { event -> when (event) { is MainEvent.LogOutRequested -> dialogViewModel.showLogout() - is MainEvent.UploadLoading -> dialogViewModel.setUploading(true) - is MainEvent.UploadSuccess -> { + + is MainEvent.UploadLoading -> dialogViewModel.setUploading(true) + + is MainEvent.UploadSuccess -> { dialogViewModel.setUploading(false) context.getSystemService() ?.setPrimaryClip(ClipData.newPlainText("dankchat_media_url", event.url)) @@ -59,28 +61,28 @@ fun MainScreenEventHandler( val result = snackbarHostState.showSnackbar( message = resources.getString(R.string.snackbar_image_uploaded, event.url), actionLabel = resources.getString(R.string.snackbar_paste), - duration = SnackbarDuration.Long + duration = SnackbarDuration.Long, ) if (result == SnackbarResult.ActionPerformed) { chatInputViewModel.insertText(event.url) } } - is MainEvent.UploadFailed -> { + 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.Long) } - is MainEvent.OpenChannel -> { + is MainEvent.OpenChannel -> { channelTabViewModel.selectTab( - preferenceStore.channels.indexOf(event.channel) + preferenceStore.channels.indexOf(event.channel), ) (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) } - else -> Unit + else -> Unit } } } @@ -92,7 +94,7 @@ fun MainScreenEventHandler( lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { authStateCoordinator.events.collect { event -> when (event) { - is AuthEvent.LoggedIn -> { + is AuthEvent.LoggedIn -> { launch { delay(2000) snackbarHostState.currentSnackbarData?.dismiss() @@ -110,7 +112,7 @@ fun MainScreenEventHandler( ) } - else -> Unit + else -> Unit } } } @@ -128,12 +130,12 @@ fun MainScreenEventHandler( 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) + 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.Long + duration = SnackbarDuration.Long, ) 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 index 346828b96..e32af74bd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -23,16 +23,16 @@ 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.message.MessageOptionsParams import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import kotlinx.collections.immutable.ImmutableMap 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 /** * Callbacks for chat message interactions within the pager. @@ -86,7 +86,7 @@ internal fun MainScreenPagerContent( LinearProgressIndicator( modifier = Modifier .fillMaxWidth() - .padding(paddingValues) + .padding(paddingValues), ) return@Box } @@ -101,13 +101,13 @@ internal fun MainScreenPagerContent( Column( modifier = Modifier .fillMaxSize() - .padding(top = paddingValues.calculateTopPadding()) + .padding(top = paddingValues.calculateTopPadding()), ) { Box(modifier = Modifier.fillMaxSize()) { HorizontalPager( state = composePagerState, modifier = Modifier.fillMaxSize(), - key = { index -> pagerState.channels.getOrNull(index)?.value ?: index } + key = { index -> pagerState.channels.getOrNull(index)?.value ?: index }, ) { page -> if (page in pagerState.channels.indices) { val channel = pagerState.channels[page] @@ -116,7 +116,7 @@ internal fun MainScreenPagerContent( onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> val shouldOpenPopup = when (userLongClickBehavior) { UserLongClickBehavior.MentionsUser -> !isLongPress - UserLongClickBehavior.OpensPopup -> isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress } if (shouldOpenPopup) { callbacks.onShowUserPopup( @@ -125,8 +125,8 @@ internal fun MainScreenPagerContent( targetUserName = UserName(userName), targetDisplayName = DisplayName(displayName), channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) + badges = badges.map { it.badge }, + ), ) } else { callbacks.onMentionUser(UserName(userName), DisplayName(displayName)) @@ -140,8 +140,8 @@ internal fun MainScreenPagerContent( fullMessage = fullMessage, canModerate = isLoggedIn, canReply = isLoggedIn, - canCopy = true - ) + canCopy = true, + ), ) }, onEmoteClick = { emotes -> @@ -158,16 +158,17 @@ internal fun MainScreenPagerContent( top = chatTopPadding + 56.dp, bottom = paddingValues.calculateBottomPadding() + when { effectiveShowInput -> inputHeightDp - !isFullscreen -> when { + + !isFullscreen -> when { helperTextHeightDp > 0.dp -> helperTextHeightDp - else -> max(navBarHeightDp, effectiveRoundedCorner) + else -> max(navBarHeightDp, effectiveRoundedCorner) } - else -> when { + else -> when { helperTextHeightDp > 0.dp -> helperTextHeightDp - else -> effectiveRoundedCorner + else -> effectiveRoundedCorner } - } + }, ), scrollModifier = if (callbacks.scrollConnection != null) Modifier.nestedScroll(callbacks.scrollConnection) else Modifier, onScrollToBottom = callbacks.onScrollToBottom, 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 index f986137b2..36356f10d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -29,13 +29,13 @@ import org.koin.android.annotation.KoinViewModel /** * Minimal coordinator ViewModel for MainScreen. - * + * * Individual components have their own ViewModels: * - ChannelTabViewModel - Tab row state * - ChannelPagerViewModel - Pager state * - ChatInputViewModel - Input state * - ChannelManagementViewModel - Channel operations - * + * * This ViewModel only handles truly global concerns. */ @OptIn(FlowPreview::class) @@ -87,11 +87,11 @@ class MainScreenViewModel( appearance.copy(inputActions = actions + InputAction.Debug) } - !enabled && InputAction.Debug in actions -> { + !enabled && InputAction.Debug in actions -> { appearance.copy(inputActions = actions - InputAction.Debug) } - else -> appearance + else -> appearance } } } @@ -147,8 +147,6 @@ class MainScreenViewModel( } } - - fun toggleInput() { viewModelScope.launch { appearanceSettingsDataStore.update { it.copy(showInput = !it.showInput) } 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 index b27f99c44..5730a645f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -112,7 +112,7 @@ fun QuickActionsMenu( onClick = { when { tourState.configureActionsTooltipState != null -> tourState.onAdvance?.invoke() - else -> onConfigureClick() + else -> onConfigureClick() } }, leadingIcon = { @@ -143,26 +143,17 @@ fun QuickActionsMenu( } } - else -> configureItem() + else -> configureItem() } } } } @Immutable -private data class OverflowItem( - val labelRes: Int, - val icon: ImageVector, -) +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( +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, ) @@ -172,35 +163,35 @@ private fun getOverflowItem( icon = Icons.Default.History, ) - InputAction.Stream -> when { + InputAction.Stream -> when { hasStreamData || isStreamActive -> OverflowItem( labelRes = if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream, icon = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, ) - else -> null + else -> null } - InputAction.ModActions -> when { + InputAction.ModActions -> when { isModerator -> OverflowItem( labelRes = R.string.menu_mod_actions, icon = Icons.Default.Shield, ) - else -> null + else -> null } - InputAction.Fullscreen -> OverflowItem( + 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( + InputAction.HideInput -> OverflowItem( labelRes = R.string.menu_hide_input, icon = Icons.Default.VisibilityOff, ) - InputAction.Debug -> OverflowItem( + InputAction.Debug -> OverflowItem( labelRes = R.string.input_action_debug, icon = Icons.Default.BugReport, ) @@ -208,8 +199,8 @@ private fun getOverflowItem( 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 + InputAction.LastMessage -> inputEnabled && hasLastMessage + InputAction.Stream, InputAction.ModActions -> inputEnabled } /** @@ -217,11 +208,7 @@ private fun isActionEnabled(action: InputAction, inputEnabled: Boolean, hasLastM */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun EndCaretTourTooltip( - text: String, - onAction: () -> Unit, - onSkip: () -> Unit, -) { +private fun EndCaretTourTooltip(text: String, onAction: () -> Unit, onSkip: () -> Unit) { val containerColor = MaterialTheme.colorScheme.secondaryContainer Row(verticalAlignment = Alignment.CenterVertically) { Surface( @@ -234,7 +221,7 @@ private fun EndCaretTourTooltip( Column( modifier = Modifier .padding(horizontal = 16.dp) - .padding(top = 12.dp, bottom = 8.dp) + .padding(top = 12.dp, bottom = 8.dp), ) { Text( text = text, @@ -272,18 +259,11 @@ private fun EndCaretTourTooltip( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun rememberStartAlignedTooltipPositionProvider( - spacingBetweenTooltipAndAnchor: Dp = 4.dp, -): PopupPositionProvider { +private 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 { + override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { val startX = anchorBounds.left - popupContentSize.width - spacingPx return if (startX >= 0) { // Fits to the start — vertically center on anchor 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 index 6f9caddaf..3fe8c9743 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt @@ -14,9 +14,7 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.data.UserName @Stable -internal class StreamToolbarState( - val alpha: Animatable, -) { +internal class StreamToolbarState(val alpha: Animatable) { var heightDp by mutableStateOf(0.dp) private var prevHasVisibleStream by mutableStateOf(false) private var isKeyboardClosingWithStream by mutableStateOf(false) @@ -34,7 +32,7 @@ internal class StreamToolbarState( if (hasVisibleStream) wasKeyboardClosingWithStream = false when { - keyboardClosingWithStream -> { + keyboardClosingWithStream -> { alpha.animateTo(0f, tween(durationMillis = 150)) } @@ -53,11 +51,7 @@ internal class StreamToolbarState( } @Composable -internal fun rememberStreamToolbarState( - currentStream: UserName?, - isKeyboardVisible: Boolean, - imeTargetBottom: Int, -): StreamToolbarState { +internal fun rememberStreamToolbarState(currentStream: UserName?, isKeyboardVisible: Boolean, imeTargetBottom: Int): StreamToolbarState { val state = remember { StreamToolbarState(alpha = Animatable(0f)) } val hasVisibleStream = currentStream != null && state.heightDp > 0.dp 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 index 14423b99e..49bfe0ba9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt @@ -5,21 +5,38 @@ import androidx.compose.runtime.Immutable @Immutable sealed interface ToolbarAction { data class SelectTab(val index: Int) : ToolbarAction + data class LongClickTab(val index: Int) : 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 index 82586acc0..22d2b41a1 100644 --- 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 @@ -35,9 +35,9 @@ class ChannelManagementViewModel( private val ignoresRepository: IgnoresRepository, private val channelRepository: ChannelRepository, ) : ViewModel() { - val channels: StateFlow> = - preferenceStore.getChannelsWithRenamesFlow() + preferenceStore + .getChannelsWithRenamesFlow() .map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) @@ -68,9 +68,7 @@ class ChannelManagementViewModel( } } - fun isChannelAdded(name: String): Boolean { - return preferenceStore.channels.any { it.value.equals(name, ignoreCase = true) } - } + fun isChannelAdded(name: String): Boolean = preferenceStore.channels.any { it.value.equals(name, ignoreCase = true) } fun addChannel(channel: UserName) { val current = preferenceStore.channels 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 index 92e061c9c..7714f1169 100644 --- 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 @@ -24,17 +24,19 @@ class ChannelPagerViewModel( 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()) + 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) @@ -74,7 +76,4 @@ class ChannelPagerViewModel( data class JumpTarget(val channelIndex: Int, val channel: UserName, val messageId: String) @Immutable -data class ChannelPagerUiState( - val channels: ImmutableList = persistentListOf(), - val currentPage: Int = 0 -) +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 index 3096cb92f..6540a80b6 100644 --- 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 @@ -7,18 +7,7 @@ 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, -) +data class ChannelTabUiState(val tabs: ImmutableList = persistentListOf(), val selectedIndex: Int = 0, val loading: Boolean = true) @Immutable -data class ChannelTabItem( - val channel: UserName, - val displayName: String, - val isSelected: Boolean, - val hasUnread: Boolean, - val mentionCount: Int, - val loadingState: ChannelLoadingState -) +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 index e5346662d..bf2724d5f 100644 --- 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 @@ -25,45 +25,51 @@ class ChannelTabViewModel( 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 uiState: StateFlow = preferenceStore.getChannelsWithRenamesFlow() - .flatMapLatest { channels -> - if (channels.isEmpty()) { - return@flatMapLatest flowOf(ChannelTabUiState(loading = false)) - } - - val loadingFlows = channels.map { - channelDataCoordinator.getChannelLoadingState(it.channel) - } + 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] + 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 }, ) } - ChannelTabUiState( - tabs = tabs.toImmutableList(), - selectedIndex = channels - .indexOfFirst { it.channel == active } - .coerceAtLeast(0), - loading = globalState == GlobalLoadingState.Loading - || tabs.any { it.loadingState == ChannelLoadingState.Loading }, - ) - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) fun selectTab(index: Int) { val channels = preferenceStore.channels 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 index d76fbdb6f..18d142c14 100644 --- 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 @@ -7,11 +7,7 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.utils.compose.InputBottomSheet @Composable -fun AddChannelDialog( - onDismiss: () -> Unit, - onAddChannel: (UserName) -> Unit, - isChannelAlreadyAdded: (String) -> Boolean, -) { +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), @@ -20,7 +16,7 @@ fun AddChannelDialog( validate = { input -> when { isChannelAlreadyAdded(input) -> alreadyAddedError - else -> null + else -> null } }, onConfirm = { name -> 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 index 09ce5c161..f599f20ed 100644 --- 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 @@ -6,13 +6,7 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet @Composable -fun ConfirmationDialog( - title: String, - confirmText: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, - dismissText: String = stringResource(R.string.dialog_cancel), -) { +fun ConfirmationDialog(title: String, confirmText: String, onConfirm: () -> Unit, onDismiss: () -> Unit, dismissText: String = stringResource(R.string.dialog_cancel)) { ConfirmationBottomSheet( title = title, confirmText = confirmText, 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 index 03436e580..1823dba25 100644 --- 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 @@ -13,11 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow import org.koin.android.annotation.KoinViewModel @KoinViewModel -class DialogStateViewModel( - private val preferenceStore: DankChatPreferenceStore, - private val toolsSettingsDataStore: ToolsSettingsDataStore, -) : ViewModel() { - +class DialogStateViewModel(private val preferenceStore: DankChatPreferenceStore, private val toolsSettingsDataStore: ToolsSettingsDataStore) : ViewModel() { private val _state = MutableStateFlow(DialogState()) val state: StateFlow = _state.asStateFlow() @@ -82,9 +78,10 @@ class DialogStateViewModel( // Upload val uploadHost: String - get() = runCatching { - java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host - }.getOrDefault("") + get() = + runCatching { + java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host + }.getOrDefault("") fun setPendingUploadAction(action: (() -> Unit)?) { update { copy(pendingUploadAction = action) } 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 index 8303a961b..bfba0f2f2 100644 --- 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 @@ -41,14 +41,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmoteInfoDialog( - items: List, - isLoggedIn: Boolean, - onUseEmote: (String) -> Unit, - onCopyEmote: (String) -> Unit, - onOpenLink: (String) -> Unit, - onDismiss: () -> Unit, -) { +fun EmoteInfoDialog(items: List, isLoggedIn: Boolean, onUseEmote: (String) -> Unit, onCopyEmote: (String) -> Unit, onOpenLink: (String) -> Unit, onDismiss: () -> Unit) { val scope = rememberCoroutineScope() val pagerState = rememberPagerState(pageCount = { items.size }) @@ -66,7 +59,7 @@ fun EmoteInfoDialog( onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, - text = { Text(item.name) } + text = { Text(item.name) }, ) } } @@ -74,7 +67,7 @@ fun EmoteInfoDialog( HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { page -> val item = items[page] EmoteInfoContent( @@ -91,7 +84,7 @@ fun EmoteInfoDialog( onOpenLink = { onOpenLink(item.providerUrl) onDismiss() - } + }, ) } Spacer(modifier = Modifier.height(16.dp)) @@ -100,27 +93,21 @@ fun EmoteInfoDialog( } @Composable -private fun EmoteInfoContent( - item: EmoteSheetItem, - showUseEmote: Boolean, - onUseEmote: () -> Unit, - onCopyEmote: () -> Unit, - onOpenLink: () -> Unit, -) { +private fun EmoteInfoContent(item: EmoteSheetItem, showUseEmote: Boolean, onUseEmote: () -> Unit, onCopyEmote: () -> Unit, onOpenLink: () -> Unit) { Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { AsyncImage( model = item.imageUrl, contentDescription = stringResource(R.string.emote_sheet_image_description), - modifier = Modifier.size(96.dp) + modifier = Modifier.size(96.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) { @@ -128,31 +115,31 @@ private fun EmoteInfoContent( text = item.name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) Text( text = stringResource(item.emoteType), style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) item.baseName?.let { Text( text = stringResource(R.string.emote_sheet_alias_of, it), style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } item.creatorName?.let { Text( text = stringResource(R.string.emote_sheet_created_by, it), style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } Text( text = if (item.isZeroWidth) stringResource(R.string.emote_sheet_zero_width_emote) else "", style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -162,20 +149,20 @@ private fun EmoteInfoContent( 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) + 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) + 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) + colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } } 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 index facc44c40..988c62874 100644 --- 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 @@ -98,14 +98,14 @@ fun MainScreenDialogs( channels = channels, onApplyChanges = channelManagementViewModel::applyChanges, onChannelSelect = channelManagementViewModel::selectChannel, - onDismiss = dialogViewModel::dismissManageChannels + onDismiss = dialogViewModel::dismissManageChannels, ) } if (dialogState.showModActions && modActionsChannel != null) { val modActionsViewModel: ModActionsViewModel = koinViewModel( key = "mod-actions-${modActionsChannel.value}", - parameters = { parametersOf(modActionsChannel) } + parameters = { parametersOf(modActionsChannel) }, ) val shieldModeActive by modActionsViewModel.shieldModeActive.collectAsStateWithLifecycle() ModActionsDialog( @@ -117,7 +117,7 @@ fun MainScreenDialogs( chatInputViewModel.trySendMessageOrCommand(command) }, onAnnounce = { chatInputViewModel.setAnnouncing(true) }, - onDismiss = dialogViewModel::dismissModActions + onDismiss = dialogViewModel::dismissModActions, ) } @@ -213,7 +213,7 @@ fun MainScreenDialogs( dialogState.messageOptionsParams?.let { params -> val viewModel: MessageOptionsViewModel = koinViewModel( key = params.messageId, - parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) } + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) }, ) val state by viewModel.state.collectAsStateWithLifecycle() (state as? MessageOptionsState.Found)?.let { s -> @@ -260,7 +260,7 @@ fun MainScreenDialogs( onTimeout = viewModel::timeoutUser, onBan = viewModel::banUser, onUnban = viewModel::unbanUser, - onDismiss = dialogViewModel::dismissMessageOptions + onDismiss = dialogViewModel::dismissMessageOptions, ) } } @@ -268,16 +268,18 @@ fun MainScreenDialogs( dialogState.emoteInfoEmotes?.let { emotes -> val viewModel: EmoteInfoViewModel = koinViewModel( key = emotes.joinToString { it.id }, - parameters = { parametersOf(emotes) } + parameters = { parametersOf(emotes) }, ) val sheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() val whisperTarget by chatInputViewModel.whisperTarget.collectAsStateWithLifecycle() val canUseEmote = isLoggedIn && when (sheetState) { is FullScreenSheetState.Closed, - is FullScreenSheetState.Replies -> true + is FullScreenSheetState.Replies, + -> true is FullScreenSheetState.Mention, - is FullScreenSheetState.Whisper -> whisperTarget != null + is FullScreenSheetState.Whisper, + -> whisperTarget != null is FullScreenSheetState.History -> false } @@ -291,14 +293,14 @@ fun MainScreenDialogs( } }, onOpenLink = { onOpenUrl(it) }, - onDismiss = dialogViewModel::dismissEmoteInfo + onDismiss = dialogViewModel::dismissEmoteInfo, ) } dialogState.userPopupParams?.let { params -> val viewModel: UserPopupViewModel = koinViewModel( key = "${params.targetUserId}${params.channel?.value.orEmpty()}", - parameters = { parametersOf(params) } + parameters = { parametersOf(params) }, ) val state by viewModel.userPopupState.collectAsStateWithLifecycle() UserPopupDialog( @@ -342,11 +344,7 @@ fun MainScreenDialogs( } @Composable -private fun UploadDisclaimerSheet( - host: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { +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) 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 index 6c3b36a4f..b037a6ada 100644 --- 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 @@ -66,12 +66,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun ManageChannelsDialog( - channels: List, - onApplyChanges: (List) -> Unit, - onChannelSelect: (UserName) -> Unit, - onDismiss: () -> Unit, -) { +fun ManageChannelsDialog(channels: List, onApplyChanges: (List) -> Unit, onChannelSelect: (UserName) -> Unit, onDismiss: () -> Unit) { var channelToDelete by remember { mutableStateOf(null) } var editingChannel by remember { mutableStateOf(null) } @@ -117,8 +112,8 @@ fun ManageChannelsDialog( shadowElevation = elevation, color = when { isDragging -> MaterialTheme.colorScheme.surfaceContainerHighest - else -> Color.Transparent - } + else -> Color.Transparent + }, ) { Column { ChannelItem( @@ -126,7 +121,7 @@ fun ManageChannelsDialog( isEditing = editingChannel == channelWithRename.channel, modifier = Modifier.longPressDraggableHandle( onDragStarted = { /* Optional haptic feedback here */ }, - onDragStopped = { /* Optional haptic feedback here */ } + onDragStopped = { /* Optional haptic feedback here */ }, ), onNavigate = { onApplyChanges(localChannels.toList()) @@ -136,7 +131,7 @@ fun ManageChannelsDialog( onEdit = { editingChannel = when (editingChannel) { channelWithRename.channel -> null - else -> channelWithRename.channel + else -> channelWithRename.channel } }, onRename = { newName -> @@ -144,12 +139,12 @@ fun ManageChannelsDialog( localChannels[index] = localChannels[index].copy(rename = rename) editingChannel = null }, - onDelete = { channelToDelete = channelWithRename.channel } + onDelete = { channelToDelete = channelWithRename.channel }, ) if (index < localChannels.lastIndex) { HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), ) } } @@ -162,7 +157,7 @@ fun ManageChannelsDialog( Text( text = stringResource(R.string.no_channels_added), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp) + modifier = Modifier.padding(16.dp), ) } } @@ -201,13 +196,13 @@ private fun ChannelItem( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(R.drawable.ic_drag_handle), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), ) Text( @@ -223,27 +218,27 @@ private fun ChannelItem( style = MaterialTheme.typography.bodyLarge, modifier = Modifier .weight(1f) - .padding(horizontal = 8.dp) + .padding(horizontal = 8.dp), ) IconButton(onClick = onNavigate) { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = stringResource(R.string.open_channel) + contentDescription = stringResource(R.string.open_channel), ) } IconButton(onClick = onEdit) { Icon( painter = painterResource(R.drawable.ic_edit), - contentDescription = stringResource(R.string.edit_dialog_title) + contentDescription = stringResource(R.string.edit_dialog_title), ) } IconButton(onClick = onDelete) { Icon( painter = painterResource(R.drawable.ic_delete_outline), - contentDescription = stringResource(R.string.remove_channel) + contentDescription = stringResource(R.string.remove_channel), ) } } @@ -263,17 +258,14 @@ private fun ChannelItem( } @Composable -private fun InlineRenameField( - channelWithRename: ChannelWithRename, - onRename: (String?) -> Unit, -) { +private fun InlineRenameField(channelWithRename: ChannelWithRename, onRename: (String?) -> Unit) { val initialText = channelWithRename.rename?.value ?: "" var renameText by remember(channelWithRename.channel) { mutableStateOf( TextFieldValue( text = initialText, - selection = TextRange(initialText.length) - ) + selection = TextRange(initialText.length), + ), ) } val focusRequester = remember { FocusRequester() } @@ -311,7 +303,7 @@ private fun InlineRenameField( IconButton(onClick = { onRename(null) }) { Icon( painter = painterResource(R.drawable.ic_clear), - contentDescription = stringResource(R.string.clear) + contentDescription = stringResource(R.string.clear), ) } } 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 index 06e2be644..6629d60ef 100644 --- 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 @@ -94,27 +94,51 @@ fun MessageOptionsDialog( transitionSpec = { when { targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() } }, - label = "MessageOptionsContent" + label = "MessageOptionsContent", ) { currentView -> when (currentView) { - null -> MessageOptionsMainView( + 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() }, + 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 }, @@ -128,7 +152,7 @@ fun MessageOptionsDialog( onBack = { subView = null }, ) - MessageOptionsSubView.Ban -> ConfirmationSubView( + MessageOptionsSubView.Ban -> ConfirmationSubView( title = stringResource(R.string.confirm_user_ban_message), confirmText = stringResource(R.string.confirm_user_ban_positive_button), onConfirm = { @@ -138,7 +162,7 @@ fun MessageOptionsDialog( onBack = { subView = null }, ) - MessageOptionsSubView.Delete -> ConfirmationSubView( + MessageOptionsSubView.Delete -> ConfirmationSubView( title = stringResource(R.string.confirm_user_delete_message), confirmText = stringResource(R.string.confirm_user_delete_positive_button), onConfirm = { @@ -181,7 +205,7 @@ private fun MessageOptionsMainView( Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp) + .padding(bottom = 16.dp), ) { if (canReply) { MessageOptionItem(Icons.AutoMirrored.Filled.Reply, stringResource(R.string.message_reply), onReply) @@ -230,24 +254,17 @@ private fun MessageOptionsMainView( } @Composable -private fun MessageOptionItem( - icon: ImageVector, - text: String, - onClick: () -> Unit -) { +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) + colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } @Composable -private fun TimeoutSubView( - onConfirm: (Int) -> Unit, - onBack: () -> Unit, -) { +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() @@ -298,12 +315,7 @@ private fun TimeoutSubView( } @Composable -private fun ConfirmationSubView( - title: String, - confirmText: String, - onConfirm: () -> Unit, - onBack: () -> Unit, -) { +private fun ConfirmationSubView(title: String, confirmText: String, onConfirm: () -> Unit, onBack: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() 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 index 9890d5a1a..d4db8ed9f 100644 --- 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 @@ -89,12 +89,12 @@ private val FOLLOWER_MODE_PRESETS = listOf( @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) + 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) + else -> stringResource(R.string.room_state_duration_months, minutes / 43200) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -121,13 +121,13 @@ fun ModActionsDialog( transitionSpec = { when { targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() } }, - label = "ModActionsContent" + label = "ModActionsContent", ) { currentView -> when (currentView) { - null -> ModActionsMainView( + null -> ModActionsMainView( roomState = roomState, isBroadcaster = isBroadcaster, isStreamActive = isStreamActive, @@ -139,7 +139,7 @@ fun ModActionsDialog( onDismiss = onDismiss, ) - SubView.SlowMode -> PresetChips( + SubView.SlowMode -> PresetChips( titleRes = R.string.room_state_slow_mode, presets = SLOW_MODE_PRESETS, formatLabel = { stringResource(R.string.room_state_duration_seconds, it) }, @@ -150,7 +150,7 @@ fun ModActionsDialog( onCustomClick = { subView = SubView.SlowModeCustom }, ) - SubView.SlowModeCustom -> UserInputSubView( + SubView.SlowModeCustom -> UserInputSubView( titleRes = R.string.room_state_slow_mode, hintRes = R.string.seconds, defaultValue = "30", @@ -162,7 +162,7 @@ fun ModActionsDialog( onDismiss = onDismiss, ) - SubView.FollowerMode -> FollowerPresetChips( + SubView.FollowerMode -> FollowerPresetChips( onPresetClick = { preset -> onSendCommand("/followers ${preset.commandArg}") onDismiss() @@ -182,7 +182,7 @@ fun ModActionsDialog( onDismiss = onDismiss, ) - SubView.CommercialPresets -> PresetChips( + SubView.CommercialPresets -> PresetChips( titleRes = R.string.mod_actions_commercial, presets = COMMERCIAL_PRESETS, formatLabel = { stringResource(R.string.room_state_duration_seconds, it) }, @@ -193,7 +193,7 @@ fun ModActionsDialog( onCustomClick = null, ) - SubView.RaidInput -> UserInputSubView( + SubView.RaidInput -> UserInputSubView( titleRes = R.string.mod_actions_raid, hintRes = R.string.mod_actions_channel_hint, onConfirm = { target -> @@ -203,7 +203,7 @@ fun ModActionsDialog( onDismiss = onDismiss, ) - SubView.ShoutoutInput -> UserInputSubView( + SubView.ShoutoutInput -> UserInputSubView( titleRes = R.string.mod_actions_shoutout, hintRes = R.string.mod_actions_username_hint, onConfirm = { target -> @@ -213,7 +213,7 @@ fun ModActionsDialog( onDismiss = onDismiss, ) - SubView.ShieldModeConfirm -> ShieldModeConfirmSubView( + SubView.ShieldModeConfirm -> ShieldModeConfirmSubView( onConfirm = { onSendCommand("/shield") onDismiss() @@ -221,7 +221,7 @@ fun ModActionsDialog( onBack = { subView = null }, ) - SubView.ClearChatConfirm -> ClearChatConfirmSubView( + SubView.ClearChatConfirm -> ClearChatConfirmSubView( onConfirm = { onSendCommand("/clear") onDismiss() @@ -281,7 +281,7 @@ private fun ModActionsMainView( onDismiss() } - else -> onShowSubView(SubView.ShieldModeConfirm) + else -> onShowSubView(SubView.ShieldModeConfirm) } }, label = { Text(stringResource(R.string.mod_actions_shield_mode)) }, @@ -358,12 +358,7 @@ private fun SectionHeader(title: String) { @OptIn(ExperimentalLayoutApi::class) @Composable -private fun RoomStateModeChips( - roomState: RoomState?, - onSendCommand: (String) -> Unit, - onShowSubView: (SubView) -> Unit, - onDismiss: () -> Unit, -) { +private fun RoomStateModeChips(roomState: RoomState?, onSendCommand: (String) -> Unit, onShowSubView: (SubView) -> Unit, onDismiss: () -> Unit) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -409,7 +404,7 @@ private fun RoomStateModeChips( onDismiss() } - else -> onShowSubView(SubView.SlowMode) + else -> onShowSubView(SubView.SlowMode) } }, label = { @@ -449,7 +444,7 @@ private fun RoomStateModeChips( onDismiss() } - else -> onShowSubView(SubView.FollowerMode) + else -> onShowSubView(SubView.FollowerMode) } }, label = { @@ -467,13 +462,7 @@ private fun RoomStateModeChips( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun PresetChips( - titleRes: Int, - presets: List, - formatLabel: @Composable (Int) -> String, - onPresetClick: (Int) -> Unit, - onCustomClick: (() -> Unit)?, -) { +private fun PresetChips(titleRes: Int, presets: List, formatLabel: @Composable (Int) -> String, onPresetClick: (Int) -> Unit, onCustomClick: (() -> Unit)?) { Column( modifier = Modifier .fillMaxWidth() @@ -511,10 +500,7 @@ private fun PresetChips( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun FollowerPresetChips( - onPresetClick: (FollowerPreset) -> Unit, - onCustomClick: () -> Unit, -) { +private fun FollowerPresetChips(onPresetClick: (FollowerPreset) -> Unit, onCustomClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() @@ -549,14 +535,7 @@ private fun FollowerPresetChips( } @Composable -private fun UserInputSubView( - titleRes: Int, - hintRes: Int, - onConfirm: (String) -> Unit, - defaultValue: String = "", - keyboardType: KeyboardType = KeyboardType.Text, - onDismiss: () -> Unit = {}, -) { +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() } @@ -621,10 +600,7 @@ private fun UserInputSubView( } @Composable -private fun ClearChatConfirmSubView( - onConfirm: () -> Unit, - onBack: () -> Unit, -) { +private fun ClearChatConfirmSubView(onConfirm: () -> Unit, onBack: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() @@ -658,10 +634,7 @@ private fun ClearChatConfirmSubView( } @Composable -private fun ShieldModeConfirmSubView( - onConfirm: () -> Unit, - onBack: () -> Unit, -) { +private fun ShieldModeConfirmSubView(onConfirm: () -> Unit, onBack: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() 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 index 23acc5acf..3d533b8e1 100644 --- 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 @@ -21,7 +21,6 @@ class ModActionsViewModel( private val authDataStore: AuthDataStore, private val channelRepository: ChannelRepository, ) : ViewModel() { - private val _shieldModeActive = MutableStateFlow(null) val shieldModeActive: StateFlow = _shieldModeActive.asStateFlow() @@ -36,7 +35,8 @@ class ModActionsViewModel( viewModelScope.launch { val channelId = channelRepository.getChannel(channel)?.id ?: return@launch val moderatorId = authDataStore.userIdString ?: return@launch - helixApiClient.getShieldMode(channelId, moderatorId) + helixApiClient + .getShieldMode(channelId, moderatorId) .onSuccess { _shieldModeActive.value = it.isActive } } } 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 index 1ed1db539..ea64e6035 100644 --- 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 @@ -60,7 +60,7 @@ fun ChatBottomBar( enter = EnterTransition.None, exit = when { instantHide -> ExitTransition.None - else -> slideOutVertically(targetOffsetY = { it }) + else -> slideOutVertically(targetOffsetY = { it }) }, ) { ChatInputLayout( @@ -82,7 +82,7 @@ fun ChatBottomBar( isRepeatedSendEnabled = isRepeatedSendEnabled, modifier = Modifier.onGloballyPositioned { coordinates -> onInputHeightChange(coordinates.size.height) - } + }, ) } @@ -100,14 +100,15 @@ fun ChatBottomBar( PaddingValues(start = 16.dp, end = rcPadding.calculateEndPadding(direction)) } - isFullscreen -> rememberRoundedCornerHorizontalPadding(fallback = 16.dp) - else -> PaddingValues(horizontal = 16.dp) + isFullscreen -> rememberRoundedCornerHorizontalPadding(fallback = 16.dp) + + else -> PaddingValues(horizontal = 16.dp) } Surface( color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier .fillMaxWidth() - .onGloballyPositioned { onHelperTextHeightChange(it.size.height) } + .onGloballyPositioned { onHelperTextHeightChange(it.size.height) }, ) { Text( text = helperText, @@ -120,7 +121,7 @@ fun ChatBottomBar( .padding(horizontalPadding) .padding(vertical = 6.dp) .basicMarquee(), - textAlign = TextAlign.Start + textAlign = TextAlign.Start, ) } } 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 index f5c02285f..238cb4a44 100644 --- 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 @@ -2,9 +2,9 @@ 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.animateContentSize import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -86,22 +86,22 @@ 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.platform.LocalDensity 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.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.utils.resolve -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.Constraints 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.resolve import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException @@ -152,11 +152,11 @@ fun ChatInputLayout( 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.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) } @@ -167,7 +167,7 @@ fun ChatInputLayout( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent + errorIndicatorColor = Color.Transparent, ) val defaultColors = TextFieldDefaults.colors() val surfaceColor = if (enabled) { @@ -180,10 +180,10 @@ fun ChatInputLayout( val effectiveActions = remember(inputActions, isModerator, hasStreamData, isStreamActive, debugMode) { inputActions.filter { action -> when (action) { - InputAction.Stream -> hasStreamData || isStreamActive + InputAction.Stream -> hasStreamData || isStreamActive InputAction.ModActions -> isModerator - InputAction.Debug -> debugMode - else -> true + InputAction.Debug -> debugMode + else -> true } }.toImmutableList() } @@ -193,31 +193,31 @@ fun ChatInputLayout( var showConfigSheet by remember { mutableStateOf(false) } val topEndRadius by animateDpAsState( targetValue = if (quickActionsExpanded) 0.dp else 24.dp, - label = "topEndCornerRadius" + label = "topEndCornerRadius", ) val inputContent: @Composable () -> Unit = { Surface( shape = RoundedCornerShape(topStart = 24.dp, topEnd = topEndRadius), color = surfaceColor, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Column( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() + .navigationBarsPadding(), ) { // Input mode overlay header AnimatedVisibility( visible = overlay != InputOverlay.None, enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + 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.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 -> "" + InputOverlay.None -> "" } InputOverlayHeader( text = headerText, @@ -240,13 +240,14 @@ fun ChatInputLayout( modifier = Modifier.height(IntrinsicSize.Min), ) { when (characterCounter) { - is CharacterCounterState.Hidden -> Unit + is CharacterCounterState.Hidden -> Unit + is CharacterCounterState.Visible -> { Text( text = characterCounter.text, color = when { characterCounter.isOverLimit -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurfaceVariant }, style = MaterialTheme.typography.labelSmall, ) @@ -273,10 +274,10 @@ fun ChatInputLayout( shape = RoundedCornerShape(0.dp), lineLimits = TextFieldLineLimits.MultiLine( minHeightInLines = 1, - maxHeightInLines = 5 + maxHeightInLines = 5, ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - onKeyboardAction = { if (canSend) onSend() } + onKeyboardAction = { if (canSend) onSend() }, ) // Helper text (roomstate + live info) @@ -286,7 +287,7 @@ fun ChatInputLayout( AnimatedVisibility( visible = !helperText.isEmpty, enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + exit = shrinkVertically() + fadeOut(), ) { val combinedText = listOfNotNull(roomStateText.ifEmpty { null }, streamInfoText).joinToString(separator = " - ") val textMeasurer = rememberTextMeasurer() @@ -340,12 +341,12 @@ fun ChatInputLayout( AnimatedVisibility( visible = isUploading || isLoading, enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + exit = shrinkVertically() + fadeOut(), ) { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) + .padding(horizontal = 16.dp, vertical = 4.dp), ) } @@ -435,13 +436,13 @@ fun ChatInputLayout( tourState = tourState, onActionClick = { action -> when (action) { - InputAction.Search -> onSearchClick() + InputAction.Search -> onSearchClick() InputAction.LastMessage -> onLastMessageClick() - InputAction.Stream -> onToggleStream() - InputAction.ModActions -> onModActions() - InputAction.Fullscreen -> onToggleFullscreen() - InputAction.HideInput -> onToggleInput() - InputAction.Debug -> onDebugInfoClick() + InputAction.Stream -> onToggleStream() + InputAction.ModActions -> onModActions() + InputAction.Fullscreen -> onToggleFullscreen() + InputAction.HideInput -> onToggleInput() + InputAction.Debug -> onDebugInfoClick() } onOverflowExpandedChange(false) }, @@ -464,16 +465,10 @@ fun ChatInputLayout( } @Composable -private fun SendButton( - enabled: Boolean, - isRepeatedSendEnabled: Boolean, - onSend: () -> Unit, - onRepeatedSendChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { +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 + else -> MaterialTheme.colorScheme.primary } val gestureModifier = when { @@ -488,13 +483,13 @@ private fun SendButton( ) } - enabled -> Modifier.clickable( + enabled -> Modifier.clickable( interactionSource = null, indication = null, onClick = onSend, ) - else -> Modifier + else -> Modifier } Box( @@ -529,29 +524,33 @@ private fun InputActionButton( onDebugInfoClick: () -> Unit = {}, ) { val (icon, contentDescription, onClick) = when (action) { - InputAction.Search -> Triple(Icons.Default.Search, R.string.message_history, onSearchClick) + InputAction.Search -> Triple(Icons.Default.Search, R.string.message_history, onSearchClick) + InputAction.LastMessage -> Triple(Icons.Default.History, R.string.resume_scroll, onLastMessageClick) - InputAction.Stream -> Triple( + + InputAction.Stream -> Triple( if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, R.string.toggle_stream, onToggleStream, ) - InputAction.ModActions -> Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) - InputAction.Fullscreen -> Triple( + InputAction.ModActions -> Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) + + InputAction.Fullscreen -> Triple( if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, R.string.toggle_fullscreen, onToggleFullscreen, ) - InputAction.HideInput -> Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) - InputAction.Debug -> Triple(Icons.Default.BugReport, R.string.input_action_debug, onDebugInfoClick) + InputAction.HideInput -> Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) + + InputAction.Debug -> Triple(Icons.Default.BugReport, R.string.input_action_debug, onDebugInfoClick) } val actionEnabled = when (action) { InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true - InputAction.LastMessage -> enabled && hasLastMessage - InputAction.Stream, InputAction.ModActions -> enabled + InputAction.LastMessage -> enabled && hasLastMessage + InputAction.Stream, InputAction.ModActions -> enabled } IconButton( @@ -567,38 +566,35 @@ private fun InputActionButton( } @Composable -private fun InputOverlayHeader( - text: String, - onDismiss: () -> Unit, -) { +private fun InputOverlayHeader(text: String, onDismiss: () -> Unit) { Column { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp) + .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 + fontWeight = FontWeight.Bold, ) IconButton( onClick = onDismiss, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.dialog_dismiss), - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), ) } } HorizontalDivider( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier.padding(horizontal = 16.dp), ) } } @@ -636,7 +632,7 @@ private fun InputActionsRow( BoxWithConstraints( modifier = Modifier .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), ) { val iconSize = 40.dp // Fixed slots: emote + overflow + send (+ whisper if present) @@ -649,7 +645,7 @@ private fun InputActionsRow( Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { // Emote/Keyboard Button (start-aligned, always visible) IconButton( @@ -660,12 +656,12 @@ private fun InputActionsRow( onEmoteClick() }, enabled = enabled && !tourState.isTourActive, - modifier = Modifier.size(iconSize) + modifier = Modifier.size(iconSize), ) { Icon( imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, contentDescription = stringResource( - if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint + if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint, ), ) } @@ -751,12 +747,12 @@ private fun EndAlignedActionGroup( onOverflowExpandedChange(!quickActionsExpanded) } }, - modifier = Modifier.size(iconSize) + modifier = Modifier.size(iconSize), ) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -774,7 +770,7 @@ private fun EndAlignedActionGroup( if (onNewWhisper != null) { IconButton( onClick = onNewWhisper, - modifier = Modifier.size(iconSize) + modifier = Modifier.size(iconSize), ) { Icon( imageVector = Icons.Default.AddComment, @@ -818,5 +814,3 @@ private fun EndAlignedActionGroup( modifier = Modifier.size(44.dp), ) } - - 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 index ebebc5249..dc57f1a2b 100644 --- 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 @@ -34,8 +34,11 @@ data class ChatInputUiState( @Stable sealed interface InputOverlay { data object None : InputOverlay + data class Reply(val name: UserName) : InputOverlay + data class Whisper(val target: UserName) : InputOverlay + data object Announce : InputOverlay } @@ -48,9 +51,6 @@ sealed interface CharacterCounterState { } @Immutable -data class HelperText( - val roomStateParts: ImmutableList = persistentListOf(), - val streamInfo: String? = null, -) { +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 index 20093673b..3c2102b88 100644 --- 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 @@ -126,8 +126,11 @@ class ChatInputViewModel( ) { showModes, channel -> showModes to channel }.flatMapLatest { (showModes, channel) -> - if (!showModes || channel == null) flowOf(emptyList()) - else channelRepository.getRoomStateFlow(channel).map { it.toDisplayTextResources() } + if (!showModes || channel == null) { + flowOf(emptyList()) + } else { + channelRepository.getRoomStateFlow(channel).map { it.toDisplayTextResources() } + } }.distinctUntilChanged() .map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) @@ -188,7 +191,6 @@ class ChatInputViewModel( } } } - } fun uiState(externalSheetState: StateFlow, externalMentionTab: StateFlow): StateFlow { @@ -209,8 +211,11 @@ class ChatInputViewModel( suggestions, chatChannelProvider.activeChannel, chatChannelProvider.activeChannel.flatMapLatest { channel -> - if (channel == null) flowOf(ConnectionState.DISCONNECTED) - else chatConnector.getConnectionState(channel) + if (channel == null) { + flowOf(ConnectionState.DISCONNECTED) + } else { + chatConnector.getConnectionState(channel) + } }, combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b }, ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, autoDisableInput) -> @@ -267,6 +272,7 @@ class ChatInputViewModel( } ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn + ConnectionState.DISCONNECTED -> InputState.Disconnected } @@ -344,6 +350,7 @@ class ChatInputViewModel( val commandResult = runCatching { when (chatState) { FullScreenSheetState.Whisper -> commandRepository.checkForWhisperCommand(message, skipSuspendingCommands) + else -> { val roomState = channelRepository.getRoomState(channel) ?: return@launch val userState = userStateRepository.userState.value @@ -359,7 +366,7 @@ class ChatInputViewModel( when (commandResult) { is CommandResult.Accepted, is CommandResult.Blocked, - -> Unit + -> Unit is CommandResult.IrcCommand -> { chatRepository.sendMessage(message, replyIdOrNull, forceIrc = true) @@ -383,6 +390,7 @@ class ChatInputViewModel( } is CommandResult.AcceptedWithResponse -> chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) + is CommandResult.Message -> { chatRepository.sendMessage(commandResult.message, replyIdOrNull) setReplying(false) @@ -496,12 +504,7 @@ class ChatInputViewModel( } } -internal data class SuggestionReplacementResult( - val replaceStart: Int, - val replaceEnd: Int, - val replacement: String, - val newCursorPos: Int, -) +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 = ' ' 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 index ad55236b9..7a88a905c 100644 --- 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 @@ -49,12 +49,7 @@ private const val MAX_INPUT_ACTIONS = 4 @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun InputActionConfigSheet( - inputActions: ImmutableList, - debugMode: Boolean, - onInputActionsChange: (ImmutableList) -> Unit, - onDismiss: () -> Unit, -) { +internal fun InputActionConfigSheet(inputActions: ImmutableList, debugMode: Boolean, onInputActionsChange: (ImmutableList) -> Unit, onDismiss: () -> Unit) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val localEnabled = remember { mutableStateListOf(*inputActions.toTypedArray()) } @@ -73,13 +68,13 @@ internal fun InputActionConfigSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp) + .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) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) // Enabled actions (reorderable, drag constrained to this section) @@ -145,7 +140,7 @@ internal fun InputActionConfigSheet( Modifier.clickable { localEnabled.add(action) } } else { Modifier - } + }, ) .padding(horizontal = 16.dp, vertical = 8.dp) .height(40.dp), @@ -186,22 +181,22 @@ internal fun InputActionConfigSheet( internal val InputAction.labelRes: Int get() = when (this) { - InputAction.Search -> R.string.input_action_search + InputAction.Search -> R.string.input_action_search InputAction.LastMessage -> R.string.input_action_last_message - InputAction.Stream -> R.string.input_action_stream + 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 + 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.Search -> Icons.Default.Search InputAction.LastMessage -> Icons.Default.History - InputAction.Stream -> Icons.Default.Videocam + InputAction.Stream -> Icons.Default.Videocam InputAction.ModActions -> Icons.Default.Shield InputAction.Fullscreen -> Icons.Default.Fullscreen - InputAction.HideInput -> Icons.Default.VisibilityOff - InputAction.Debug -> Icons.Default.BugReport + 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 index edceb610b..e37a8eff3 100644 --- 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 @@ -42,11 +42,7 @@ import com.flxrs.dankchat.ui.chat.suggestion.Suggestion import kotlinx.collections.immutable.ImmutableList @Composable -fun SuggestionDropdown( - suggestions: ImmutableList, - onSuggestionClick: (Suggestion) -> Unit, - modifier: Modifier = Modifier -) { +fun SuggestionDropdown(suggestions: ImmutableList, onSuggestionClick: (Suggestion) -> Unit, modifier: Modifier = Modifier) { AnimatedVisibility( visible = suggestions.isNotEmpty(), modifier = modifier, @@ -54,20 +50,20 @@ fun SuggestionDropdown( initialOffsetY = { fullHeight -> fullHeight / 4 }, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium - ) + stiffness = Spring.StiffnessMedium, + ), ) + fadeIn( - animationSpec = spring(stiffness = Spring.StiffnessMedium) + animationSpec = spring(stiffness = Spring.StiffnessMedium), ) + scaleIn( initialScale = 0.92f, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium - ) + stiffness = Spring.StiffnessMedium, + ), ), exit = slideOutVertically( - targetOffsetY = { fullHeight -> fullHeight / 4 } - ) + fadeOut() + scaleOut(targetScale = 0.92f) + targetOffsetY = { fullHeight -> fullHeight / 4 }, + ) + fadeOut() + scaleOut(targetScale = 0.92f), ) { val listState = rememberLazyListState() LaunchedEffect(suggestions) { @@ -79,7 +75,7 @@ fun SuggestionDropdown( .padding(horizontal = 2.dp) .fillMaxWidth(0.66f) .heightIn(max = 250.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), ) { LazyColumn( state = listState, @@ -99,17 +95,13 @@ fun SuggestionDropdown( } @Composable -private fun SuggestionItem( - suggestion: Suggestion, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { +private fun SuggestionItem(suggestion: Suggestion, onClick: () -> Unit, modifier: Modifier = Modifier) { Row( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Icon/Image based on suggestion type when (suggestion) { @@ -119,11 +111,11 @@ private fun SuggestionItem( contentDescription = suggestion.emote.code, modifier = Modifier .size(48.dp) - .padding(end = 12.dp) + .padding(end = 12.dp), ) Text( text = suggestion.emote.code, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } @@ -133,11 +125,11 @@ private fun SuggestionItem( contentDescription = null, modifier = Modifier .size(32.dp) - .padding(end = 12.dp) + .padding(end = 12.dp), ) Text( text = suggestion.name.value, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } @@ -148,11 +140,11 @@ private fun SuggestionItem( modifier = Modifier .size(32.dp) .padding(end = 12.dp) - .wrapContentSize() + .wrapContentSize(), ) Text( text = ":${suggestion.emoji.code}:", - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } @@ -162,11 +154,11 @@ private fun SuggestionItem( contentDescription = null, modifier = Modifier .size(32.dp) - .padding(end = 12.dp) + .padding(end = 12.dp), ) Text( text = suggestion.command, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } @@ -176,17 +168,17 @@ private fun SuggestionItem( contentDescription = null, modifier = Modifier .size(32.dp) - .padding(end = 12.dp) + .padding(end = 12.dp), ) Column { Text( text = suggestion.displayText ?: suggestion.keyword, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) Text( text = stringResource(suggestion.descriptionRes), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + 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 index a28d087e3..47198c8ed 100644 --- 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 @@ -35,14 +35,7 @@ data class TourOverlayState( @Suppress("ContentSlotReused") @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun OptionalTourTooltip( - tooltipState: TooltipState?, - text: String, - onAdvance: (() -> Unit)?, - onSkip: (() -> Unit)?, - focusable: Boolean = false, - content: @Composable () -> Unit, -) { +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), @@ -67,12 +60,7 @@ internal fun OptionalTourTooltip( @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun TooltipScope.TourTooltip( - text: String, - onAction: () -> Unit, - onSkip: () -> Unit, - isLast: Boolean = false, -) { +internal fun TooltipScope.TourTooltip(text: String, onAction: () -> Unit, onSkip: () -> Unit, isLast: Boolean = false) { val tourColors = TooltipDefaults.richTooltipColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, @@ -91,7 +79,7 @@ internal fun TooltipScope.TourTooltip( 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 index 354e96512..88b489cbc 100644 --- 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 @@ -36,11 +36,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DebugInfoSheet( - viewModel: DebugInfoViewModel, - sheetState: SheetState, - onDismiss: () -> Unit, -) { +fun DebugInfoSheet(viewModel: DebugInfoViewModel, sheetState: SheetState, onDismiss: () -> Unit) { val sections by viewModel.sections.collectAsStateWithLifecycle() ModalBottomSheet( @@ -93,7 +89,7 @@ private fun DebugEntryRow(entry: DebugEntry) { } } - else -> Modifier + else -> Modifier } Row( modifier = copyModifier 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 index b9c9a0b0c..d0341a686 100644 --- 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 @@ -14,11 +14,10 @@ import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel @KoinViewModel -class DebugInfoViewModel( - debugSectionRegistry: DebugSectionRegistry, -) : ViewModel() { - - val sections: StateFlow> = debugSectionRegistry.allSections() - .map { it.toImmutableList() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) +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/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt index 13b82e022..381e6814c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt @@ -50,17 +50,12 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmoteMenu( - onEmoteClick: (String, String) -> Unit, - onBackspace: () -> Unit, - modifier: Modifier = Modifier, - viewModel: EmoteMenuViewModel = koinViewModel(), -) { +fun EmoteMenu(onEmoteClick: (String, String) -> Unit, onBackspace: () -> Unit, modifier: Modifier = Modifier, viewModel: EmoteMenuViewModel = koinViewModel()) { val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val pagerState = rememberPagerState( initialPage = 0, - pageCount = { tabItems.size } + pageCount = { tabItems.size }, ) val subsGridState = rememberLazyGridState() val subsFirstHeader = tabItems.getOrNull(EmoteMenuTab.SUBS.ordinal) @@ -72,12 +67,12 @@ fun EmoteMenu( Surface( modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surfaceContainerHighest + color = MaterialTheme.colorScheme.surfaceContainerHighest, ) { Column(modifier = Modifier.fillMaxSize()) { PrimaryTabRow( selectedTabIndex = pagerState.currentPage, - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, ) { tabItems.forEachIndexed { index, tabItem -> Tab( @@ -86,13 +81,13 @@ fun EmoteMenu( 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.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) - } + EmoteMenuTab.GLOBAL -> stringResource(R.string.emote_menu_tab_global) + }, ) - } + }, ) } } @@ -104,7 +99,7 @@ fun EmoteMenu( HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), - beyondViewportPageCount = 1 + beyondViewportPageCount = 1, ) { page -> val tab = tabItems[page] val items = tab.items @@ -116,7 +111,7 @@ fun EmoteMenu( text = stringResource(R.string.no_recent_emotes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 160.dp) // Offset below logo + modifier = Modifier.padding(top = 160.dp), // Offset below logo ) } } else { @@ -127,28 +122,28 @@ fun EmoteMenu( 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) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items( items = items, key = { item -> when (item) { - is EmoteItem.Emote -> "emote-${item.emote.id}-${item.emote.code}" + 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) + is EmoteItem.Emote -> GridItemSpan(1) } }, contentType = { item -> when (item) { is EmoteItem.Header -> "header" - is EmoteItem.Emote -> "emote" + is EmoteItem.Emote -> "emote" } - } + }, ) { item -> when (item) { is EmoteItem.Header -> { @@ -157,18 +152,18 @@ fun EmoteMenu( style = MaterialTheme.typography.titleMedium, modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) + .padding(vertical = 8.dp), ) } - is EmoteItem.Emote -> { + 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) } + .clickable { onEmoteClick(item.emote.code, item.emote.id) }, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt index b380d9b51..d139b62ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt @@ -38,17 +38,12 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmoteMenuSheet( - onDismiss: () -> Unit, - onEmoteClick: (String, String) -> Unit, - sheetState: SheetState, - viewModel: EmoteMenuViewModel = koinViewModel(), -) { +fun EmoteMenuSheet(onDismiss: () -> Unit, onEmoteClick: (String, String) -> Unit, sheetState: SheetState, viewModel: EmoteMenuViewModel = koinViewModel()) { val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val pagerState = rememberPagerState( initialPage = 0, - pageCount = { tabItems.size } + pageCount = { tabItems.size }, ) ModalBottomSheet( @@ -66,13 +61,13 @@ fun EmoteMenuSheet( 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.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) - } + EmoteMenuTab.GLOBAL -> stringResource(R.string.emote_menu_tab_global) + }, ) - } + }, ) } } @@ -80,7 +75,7 @@ fun EmoteMenuSheet( HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), - beyondViewportPageCount = 1 + beyondViewportPageCount = 1, ) { page -> val items = tabItems[page].items LazyVerticalGrid( @@ -88,28 +83,28 @@ fun EmoteMenuSheet( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = 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.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) + is EmoteItem.Emote -> GridItemSpan(1) } }, contentType = { item -> when (item) { is EmoteItem.Header -> "header" - is EmoteItem.Emote -> "emote" + is EmoteItem.Emote -> "emote" } - } + }, ) { item -> when (item) { is EmoteItem.Header -> { @@ -118,18 +113,18 @@ fun EmoteMenuSheet( style = MaterialTheme.typography.titleMedium, modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) + .padding(vertical = 8.dp), ) } - is EmoteItem.Emote -> { + 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) } + .clickable { onEmoteClick(item.emote.code, item.emote.id) }, ) } } 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 index 1603c0822..df34a3f50 100644 --- 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 @@ -26,53 +26,55 @@ import kotlinx.coroutines.withContext import org.koin.android.annotation.KoinViewModel @KoinViewModel -class EmoteMenuViewModel( - private val chatChannelProvider: ChatChannelProvider, - private val dataRepository: DataRepository, - private val emoteUsageRepository: EmoteUsageRepository, -) : ViewModel() { - +class EmoteMenuViewModel(private val chatChannelProvider: ChatChannelProvider, private val dataRepository: DataRepository, private val emoteUsageRepository: EmoteUsageRepository) : ViewModel() { private val activeChannel = chatChannelProvider.activeChannel - private val emotes = activeChannel - .flatMapLatestOrDefault(Emotes()) { dataRepository.getEmotes(it) } + 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 } } - } + 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(Dispatchers.Default) { - val sortedEmotes = emotes.sorted - val availableRecents = recentEmotes.mapNotNull { usage -> - sortedEmotes - .firstOrNull { it.id == usage.emoteId } - ?.copy(emoteType = EmoteType.RecentUsageEmote) - } + val emoteTabItems: StateFlow> = + combine(emotes, recentEmotes, activeChannel) { emotes, recentEmotes, channel -> + 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 + 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 + is EmoteType.ChannelFFZEmote, + is EmoteType.ChannelBTTVEmote, + is EmoteType.ChannelSevenTVEmote, + -> EmoteMenuTab.CHANNEL - else -> EmoteMenuTab.GLOBAL - } + 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] ?: emptyList()).toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, (groupedByType[EmoteMenuTab.GLOBAL] ?: emptyList()).toEmoteItems()) }, + ).awaitAll().toImmutableList() } - listOf( - async { EmoteMenuTabItem(EmoteMenuTab.RECENT, availableRecents.toEmoteItems()) }, - async { EmoteMenuTabItem(EmoteMenuTab.SUBS, groupedByType[EmoteMenuTab.SUBS].toEmoteItemsWithFront(channel)) }, - async { EmoteMenuTabItem(EmoteMenuTab.CHANNEL, (groupedByType[EmoteMenuTab.CHANNEL] ?: emptyList()).toEmoteItems()) }, - async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, (groupedByType[EmoteMenuTab.GLOBAL] ?: emptyList()).toEmoteItems()) } - ).awaitAll().toImmutableList() - } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), - EmoteMenuTab.entries.map { EmoteMenuTabItem(it, emptyList()) }.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 index c87071a1b..d6ab8c7a8 100644 --- 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 @@ -47,10 +47,10 @@ fun FullScreenSheetOverlay( visible = isVisible, enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), - modifier = modifier.fillMaxSize() + modifier = modifier.fillMaxSize(), ) { Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { val popupOnlyClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, _ -> onUserClick( @@ -59,15 +59,15 @@ fun FullScreenSheetOverlay( targetUserName = UserName(userName), targetDisplayName = DisplayName(displayName), channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) + badges = badges.map { it.badge }, + ), ) } val mentionableClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, isLongPress -> val shouldOpenPopup = when (userLongClickBehavior) { UserLongClickBehavior.MentionsUser -> !isLongPress - UserLongClickBehavior.OpensPopup -> isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress } if (shouldOpenPopup) { onUserClick( @@ -76,8 +76,8 @@ fun FullScreenSheetOverlay( targetUserName = UserName(userName), targetDisplayName = DisplayName(displayName), channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) + badges = badges.map { it.badge }, + ), ) } else { onUserMention(UserName(userName), DisplayName(displayName)) @@ -86,6 +86,7 @@ fun FullScreenSheetOverlay( when (sheetState) { is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Mention -> { MentionSheet( mentionViewModel = mentionViewModel, @@ -103,7 +104,7 @@ fun FullScreenSheetOverlay( canReply = false, canCopy = true, canJump = true, - ) + ), ) }, onEmoteClick = onEmoteClick, @@ -129,7 +130,7 @@ fun FullScreenSheetOverlay( canReply = false, canCopy = true, canJump = false, - ) + ), ) }, onEmoteClick = onEmoteClick, @@ -154,7 +155,7 @@ fun FullScreenSheetOverlay( canReply = false, canCopy = true, canJump = true, - ) + ), ) }, bottomContentPadding = bottomContentPadding, @@ -169,7 +170,7 @@ fun FullScreenSheetOverlay( val historyClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, isLongPress -> val shouldOpenPopup = when (userLongClickBehavior) { UserLongClickBehavior.MentionsUser -> !isLongPress - UserLongClickBehavior.OpensPopup -> isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress } if (shouldOpenPopup) { onUserClick( @@ -178,8 +179,8 @@ fun FullScreenSheetOverlay( targetUserName = UserName(userName), targetDisplayName = DisplayName(displayName), channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge } - ) + badges = badges.map { it.badge }, + ), ) } else { viewModel.insertText("${UserName(userName).valueOrDisplayName(DisplayName(displayName))} ") @@ -202,7 +203,7 @@ fun FullScreenSheetOverlay( canReply = false, canCopy = true, canJump = true, - ) + ), ) }, onEmoteClick = onEmoteClick, 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 index de3b58b00..6acfb42e8 100644 --- 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 @@ -71,7 +71,7 @@ fun MentionSheet( val density = LocalDensity.current val pagerState = rememberPagerState( initialPage = if (initialisWhisperTab) 1 else 0, - pageCount = { 2 } + pageCount = { 2 }, ) var backProgress by remember { mutableFloatStateOf(0f) } var toolbarVisible by remember { mutableStateOf(true) } @@ -119,7 +119,7 @@ fun MentionSheet( scaleY = scale alpha = 1f - backProgress translationY = backProgress * 100f - } + }, ) { HorizontalPager( state = pagerState, @@ -153,8 +153,8 @@ fun MentionSheet( brush = Brush.verticalGradient( 0f to sheetBackgroundColor.copy(alpha = 0.7f), 0.75f to sheetBackgroundColor.copy(alpha = 0.7f), - 1f to sheetBackgroundColor.copy(alpha = 0f) - ) + 1f to sheetBackgroundColor.copy(alpha = 0f), + ), ) .padding(top = statusBarHeight + 8.dp) .padding(bottom = 16.dp) @@ -163,7 +163,7 @@ fun MentionSheet( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { Surface( shape = MaterialTheme.shapes.extraLarge, @@ -172,7 +172,7 @@ fun MentionSheet( IconButton(onClick = onDismiss) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) + contentDescription = stringResource(R.string.back), ) } } @@ -187,14 +187,14 @@ fun MentionSheet( val isSelected = pagerState.currentPage == index val textColor = when { isSelected -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurfaceVariant } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable { scope.launch { pagerState.animateScrollToPage(index) } } .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp), ) { Text( text = stringResource(stringRes), @@ -215,7 +215,7 @@ fun MentionSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)) + .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 index 72c5c5350..266043b5c 100644 --- 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 @@ -85,7 +85,6 @@ fun MessageHistorySheet( onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, onEmoteClick: (List) -> Unit, ) { - LaunchedEffect(viewModel, initialFilter) { viewModel.setInitialQuery(initialFilter) } @@ -149,7 +148,7 @@ fun MessageHistorySheet( scaleY = scale alpha = 1f - backProgress translationY = backProgress * 100f - } + }, ) { CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( @@ -184,7 +183,7 @@ fun MessageHistorySheet( 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) @@ -235,7 +234,7 @@ fun MessageHistorySheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)) + .background(sheetBackgroundColor.copy(alpha = 0.7f)), ) } @@ -269,9 +268,7 @@ fun MessageHistorySheet( } @Composable -private fun SearchToolbar( - state: TextFieldState, -) { +private fun SearchToolbar(state: TextFieldState) { val keyboardController = LocalSoftwareKeyboardController.current val textFieldColors = TextFieldDefaults.colors( 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 index dc8fa7c16..8713e6d6a 100644 --- 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 @@ -58,7 +58,7 @@ fun RepliesSheet( ) { val viewModel: RepliesViewModel = koinViewModel( key = rootMessageId, - parameters = { parametersOf(rootMessageId) } + parameters = { parametersOf(rootMessageId) }, ) val density = LocalDensity.current var backProgress by remember { mutableFloatStateOf(0f) } @@ -103,7 +103,7 @@ fun RepliesSheet( scaleY = scale alpha = 1f - backProgress translationY = backProgress * 100f - } + }, ) { RepliesComposable( repliesViewModel = viewModel, @@ -130,8 +130,8 @@ fun RepliesSheet( brush = Brush.verticalGradient( 0f to sheetBackgroundColor.copy(alpha = 0.7f), 0.75f to sheetBackgroundColor.copy(alpha = 0.7f), - 1f to sheetBackgroundColor.copy(alpha = 0f) - ) + 1f to sheetBackgroundColor.copy(alpha = 0f), + ), ) .padding(top = statusBarHeight + 8.dp) .padding(bottom = 16.dp) @@ -139,7 +139,7 @@ fun RepliesSheet( ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { Surface( shape = MaterialTheme.shapes.extraLarge, @@ -148,7 +148,7 @@ fun RepliesSheet( IconButton(onClick = onDismiss) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) + contentDescription = stringResource(R.string.back), ) } } @@ -156,13 +156,13 @@ fun RepliesSheet( Surface( shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.padding(start = 8.dp) + modifier = Modifier.padding(start = 8.dp), ) { Text( text = stringResource(R.string.replies_title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } } @@ -175,7 +175,7 @@ fun RepliesSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)) + .background(sheetBackgroundColor.copy(alpha = 0.7f)), ) } } 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 index abdfa24ef..e3df16a86 100644 --- 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 @@ -6,21 +6,24 @@ import com.flxrs.dankchat.data.UserName @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: UserName, 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, -) +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 index be5524f88..748e0c31a 100644 --- 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 @@ -58,19 +58,17 @@ class SheetNavigationViewModel : ViewModel() { _inputSheetState.value = InputSheetState.Closed } - fun handleBackPress(): Boolean { - return when { - _inputSheetState.value != InputSheetState.Closed -> { - closeInputSheet() - true - } - - _fullScreenSheetState.value != FullScreenSheetState.Closed -> { - closeFullScreenSheet() - true - } - - else -> false + fun handleBackPress(): Boolean = when { + _inputSheetState.value != InputSheetState.Closed -> { + closeInputSheet() + true } + + _fullScreenSheetState.value != FullScreenSheetState.Closed -> { + closeFullScreenSheet() + true + } + + else -> false } } 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 index 878eeeb87..04626efa4 100644 --- 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 @@ -37,14 +37,7 @@ import com.flxrs.dankchat.data.UserName @Suppress("LambdaParameterEventTrailing") @Composable -fun StreamView( - channel: UserName, - streamViewModel: StreamViewModel, - modifier: Modifier = Modifier, - isInPipMode: Boolean = false, - fillPane: Boolean = false, - onClose: () -> Unit, -) { +fun StreamView(channel: UserName, streamViewModel: StreamViewModel, modifier: Modifier = Modifier, isInPipMode: Boolean = false, fillPane: Boolean = false, onClose: () -> Unit) { // 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). @@ -54,7 +47,7 @@ fun StreamView( streamViewModel.getOrCreateWebView().also { wv -> wv.setBackgroundColor(android.graphics.Color.TRANSPARENT) wv.webViewClient = StreamComposeWebViewClient( - onPageFinished = { isPageLoaded = true } + onPageFinished = { isPageLoaded = true }, ) } } @@ -82,13 +75,15 @@ fun StreamView( modifier = modifier .then(if (isInPipMode || fillPane) Modifier else Modifier.statusBarsPadding()) .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surface), ) { val webViewModifier = when { isInPipMode || fillPane -> Modifier.fillMaxSize() - else -> Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) + + else -> + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) } if (isPageLoaded) { @@ -97,7 +92,7 @@ fun StreamView( (webView.parent as? ViewGroup)?.removeView(webView) webView.layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + ViewGroup.LayoutParams.MATCH_PARENT, ) if (!hasBeenAttached) { hasBeenAttached = true @@ -118,7 +113,7 @@ fun StreamView( }, modifier = webViewModifier.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen - } + }, ) } else { Box(modifier = webViewModifier) @@ -134,24 +129,22 @@ fun StreamView( .size(28.dp) .background( color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f), - shape = CircleShape + shape = CircleShape, ) - .clickable(onClick = onClose) + .clickable(onClick = onClose), ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.dialog_dismiss), tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(18.dp), ) } } } } -private class StreamComposeWebViewClient( - private val onPageFinished: () -> Unit, -) : WebViewClient() { +private class StreamComposeWebViewClient(private val onPageFinished: () -> Unit) : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { if (url != null && url != BLANK_URL) { 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 index a04a290d3..c1e2e6e74 100644 --- 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 @@ -31,7 +31,7 @@ class StreamViewModel( private val hasStreamData: StateFlow = combine( chatChannelProvider.activeChannel, - streamDataRepository.streamData + streamDataRepository.streamData, ) { activeChannel, streamData -> activeChannel != null && streamData.any { it.channel == activeChannel } }.distinctUntilChanged() @@ -116,7 +116,4 @@ class StreamViewModel( } @Immutable -data class StreamState( - val currentStream: UserName? = null, - val hasStreamData: Boolean = false, -) +data class StreamState(val currentStream: UserName? = null, val hasStreamData: 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 index 8c81f8585..59e218258 100644 --- 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 @@ -10,13 +10,10 @@ 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) { - +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 @@ -30,10 +27,11 @@ class StreamWebView @JvmOverloads constructor( 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 - } + val url = + when { + isActive -> "https://player.twitch.tv/?channel=$channel&enableExtensions=true&muted=false&parent=twitch.tv" + else -> BLANK_URL + } stopLoading() loadUrl(url) @@ -61,12 +59,12 @@ class StreamWebView @JvmOverloads constructor( 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/", - ) + 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 index e741fbf5c..61663a136 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt @@ -15,48 +15,42 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class OnboardingDataStore( - context: Context, - dispatchersProvider: DispatchersProvider, - dankChatPreferenceStore: DankChatPreferenceStore, -) { - +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 { - return !currentData.hasRunExistingUserMigration && dankChatPreferenceStore.hasMessageHistoryAcknowledged - } + private val existingUserMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: OnboardingSettings): Boolean = !currentData.hasRunExistingUserMigration && dankChatPreferenceStore.hasMessageHistoryAcknowledged - override suspend fun migrate(currentData: OnboardingSettings): OnboardingSettings { - return currentData.copy( + override suspend fun migrate(currentData: OnboardingSettings): OnboardingSettings = currentData.copy( hasCompletedOnboarding = true, hasRunExistingUserMigration = true, hasShownAddChannelHint = true, hasShownToolbarHint = true, ) - } - override suspend fun cleanUp() = Unit - } + 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), - ) + 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() } - ) + val currentSettings = + settings.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) fun current() = currentSettings.value 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 index be201b8ba..0294018cf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt @@ -67,11 +67,7 @@ import org.koin.compose.viewmodel.koinViewModel private const val PAGE_COUNT = 4 @Composable -fun OnboardingScreen( - onNavigateToLogin: () -> Unit, - onComplete: () -> Unit, - modifier: Modifier = Modifier, -) { +fun OnboardingScreen(onNavigateToLogin: () -> Unit, onComplete: () -> Unit, modifier: Modifier = Modifier) { val viewModel: OnboardingViewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() @@ -145,13 +141,7 @@ fun OnboardingScreen( } @Composable -private fun OnboardingPage( - title: String, - icon: @Composable () -> Unit, - body: @Composable () -> Unit, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { +private fun OnboardingPage(title: String, icon: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit) { Column( modifier = modifier .fillMaxSize() @@ -184,10 +174,7 @@ private fun OnboardingBody(text: String) { } @Composable -private fun WelcomePage( - onStart: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun WelcomePage(onStart: () -> Unit, modifier: Modifier = Modifier) { OnboardingPage( icon = { Icon( @@ -208,13 +195,7 @@ private fun WelcomePage( } @Composable -private fun LoginPage( - loginCompleted: Boolean, - onLogin: () -> Unit, - onSkip: () -> Unit, - onContinue: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun LoginPage(loginCompleted: Boolean, onLogin: () -> Unit, onSkip: () -> Unit, onContinue: () -> Unit, modifier: Modifier = Modifier) { OnboardingPage( icon = { Icon( @@ -263,7 +244,7 @@ private fun LoginPage( } } - else -> { + else -> { Button(onClick = onLogin) { Text(stringResource(R.string.onboarding_login_button)) } @@ -279,12 +260,7 @@ private fun LoginPage( } @Composable -private fun MessageHistoryPage( - decided: Boolean, - onEnable: () -> Unit, - onDisable: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun MessageHistoryPage(decided: Boolean, onEnable: () -> Unit, onDisable: () -> Unit, modifier: Modifier = Modifier) { OnboardingPage( icon = { Icon( @@ -311,7 +287,7 @@ private fun MessageHistoryPage( append(bodyText.substring(urlStart + url.length)) } - else -> append(bodyText) + else -> append(bodyText) } } } @@ -341,15 +317,12 @@ private enum class NotificationPermissionState { Pending, Granted, Denied } @SuppressLint("InlinedApi") @Composable -private fun NotificationsPage( - onContinue: () -> Unit, - modifier: Modifier = Modifier, -) { +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() + contract = ActivityResultContracts.RequestPermission(), ) { granted -> if (granted) { onContinue() @@ -364,7 +337,8 @@ private fun NotificationsPage( lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { if (isAtLeastTiramisu && permissionState == NotificationPermissionState.Denied) { val granted = ContextCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS + context, + Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED if (granted) { onContinue() @@ -400,7 +374,7 @@ private fun NotificationsPage( } } - NotificationPermissionState.Denied -> { + NotificationPermissionState.Denied -> { Text( text = stringResource(R.string.onboarding_notifications_rationale), style = MaterialTheme.typography.bodySmall, @@ -414,7 +388,7 @@ private fun NotificationsPage( putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) } context.startActivity(intent) - } + }, ) { Text(stringResource(R.string.onboarding_notifications_open_settings)) } @@ -428,7 +402,7 @@ private fun NotificationsPage( Button( onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } + }, ) { Text(stringResource(R.string.onboarding_notifications_allow)) } @@ -448,5 +422,3 @@ private fun NotificationsPage( } } } - - 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 index cb2c5ee29..328de2039 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch - import org.koin.android.annotation.KoinViewModel data class OnboardingState( @@ -30,22 +29,22 @@ class OnboardingViewModel( 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 = + 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 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 index 9beaf1bf1..51094eecf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt @@ -125,7 +125,7 @@ class ShareUploadActivity : ComponentActivity() { onSuccess = { url -> uploadState = ShareUploadState.Success(url) }, onFailure = { error -> uploadState = ShareUploadState.Error( - error.message ?: getString(R.string.snackbar_upload_failed) + error.message ?: getString(R.string.snackbar_upload_failed), ) }, ) @@ -140,11 +140,7 @@ sealed interface ShareUploadState { } @Composable -private fun ShareUploadDialog( - state: ShareUploadState, - onRetry: () -> Unit, - onDismiss: () -> Unit, -) { +private fun ShareUploadDialog(state: ShareUploadState, onRetry: () -> Unit, onDismiss: () -> Unit) { Surface( shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh, @@ -168,7 +164,7 @@ private fun ShareUploadDialog( when (currentState) { is ShareUploadState.Loading -> LoadingContent() is ShareUploadState.Success -> SuccessContent(url = currentState.url) - is ShareUploadState.Error -> ErrorContent(message = currentState.message) + is ShareUploadState.Error -> ErrorContent(message = currentState.message) } } @@ -178,7 +174,7 @@ private fun ShareUploadDialog( verticalAlignment = Alignment.CenterVertically, ) { when (state) { - is ShareUploadState.Error -> { + is ShareUploadState.Error -> { TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_dismiss)) } 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 index 386d8e9c2..ed595b4e9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -30,10 +30,7 @@ private val TrueDarkColorScheme = darkColorScheme( * Additional color values needed for dynamic text color selection * based on background brightness. */ -data class AdaptiveColors( - val onSurfaceLight: Color, - val onSurfaceDark: Color -) +data class AdaptiveColors(val onSurfaceLight: Color, val onSurfaceDark: Color) val LocalAdaptiveColors = staticCompositionLocalOf { AdaptiveColors( @@ -43,10 +40,7 @@ val LocalAdaptiveColors = staticCompositionLocalOf { } @Composable -fun DankChatTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { +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 } @@ -56,7 +50,7 @@ fun DankChatTheme( val lightColorScheme = when { dynamicColor -> dynamicLightColorScheme(LocalContext.current) - else -> expressiveLightColorScheme() + else -> expressiveLightColorScheme() } val darkColorScheme = when { dynamicColor && trueDarkTheme -> dynamicDarkColorScheme(LocalContext.current).copy( @@ -64,8 +58,9 @@ fun DankChatTheme( background = TrueDarkColorScheme.background, ) - dynamicColor -> dynamicDarkColorScheme(LocalContext.current) - else -> darkColorScheme() + dynamicColor -> dynamicDarkColorScheme(LocalContext.current) + + else -> darkColorScheme() } val adaptiveColors = AdaptiveColors( @@ -74,7 +69,7 @@ fun DankChatTheme( ) val colors = when { darkTheme -> darkColorScheme - else -> lightColorScheme + else -> lightColorScheme } MaterialExpressiveTheme( 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 index aac62301a..9c84969b1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt @@ -55,10 +55,7 @@ data class FeatureTourUiState( @OptIn(ExperimentalMaterial3Api::class) @KoinViewModel -class FeatureTourViewModel( - private val onboardingDataStore: OnboardingDataStore, - startupValidationHolder: StartupValidationHolder, -) : ViewModel() { +class FeatureTourViewModel(private val onboardingDataStore: OnboardingDataStore, startupValidationHolder: StartupValidationHolder) : ViewModel() { // Material3 tooltip states — UI objects exposed directly, not in the StateFlow. val inputActionsTooltipState = TooltipState(isPersistent = true) @@ -76,10 +73,7 @@ class FeatureTourViewModel( val completed: Boolean = false, ) - private data class ChannelState( - val ready: Boolean = false, - val empty: Boolean = true, - ) + private data class ChannelState(val ready: Boolean = false, val empty: Boolean = true) private val _tourState = MutableStateFlow(TourInternalState()) private val _channelState = MutableStateFlow(ChannelState()) @@ -93,9 +87,9 @@ class FeatureTourViewModel( startupValidationHolder.state, ) { settings, tour, channel, hintDone, validation -> val currentStep = when { - !tour.isActive -> null + !tour.isActive -> null tour.stepIndex >= TourStep.entries.size -> null - else -> TourStep.entries[tour.stepIndex] + else -> TourStep.entries[tour.stepIndex] } FeatureTourUiState( postOnboardingStep = resolvePostOnboardingStep( @@ -149,7 +143,7 @@ class FeatureTourViewModel( // 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 + else -> 0 } val step = TourStep.entries[stepIndex] _tourState.value = TourInternalState( @@ -177,7 +171,8 @@ class FeatureTourViewModel( val nextStep = TourStep.entries.getOrNull(nextIndex) when { nextStep == null -> completeTour() - else -> { + + else -> { viewModelScope.launch { onboardingDataStore.update { it.copy(featureTourStep = nextIndex) } } @@ -229,11 +224,11 @@ class FeatureTourViewModel( } private fun tooltipStateForStep(step: TourStep): TooltipState = when (step) { - TourStep.InputActions -> inputActionsTooltipState - TourStep.OverflowMenu -> overflowMenuTooltipState + TourStep.InputActions -> inputActionsTooltipState + TourStep.OverflowMenu -> overflowMenuTooltipState TourStep.ConfigureActions -> configureActionsTooltipState - TourStep.SwipeGesture -> swipeGestureTooltipState - TourStep.RecoveryFab -> recoveryFabTooltipState + TourStep.SwipeGesture -> swipeGestureTooltipState + TourStep.RecoveryFab -> recoveryFabTooltipState } private fun resolvePostOnboardingStep( @@ -245,16 +240,24 @@ class FeatureTourViewModel( tourCompleted: Boolean, authValidated: Boolean, ): PostOnboardingStep = when { - tourCompleted -> PostOnboardingStep.Complete + 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 + + !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 + 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..45e11dafd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/AppLifecycleListener.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/AppLifecycleListener.kt @@ -11,6 +11,7 @@ import org.koin.core.annotation.Single class AppLifecycleListener(val app: Application) { sealed interface AppLifecycle { data object Background : AppLifecycle + data object Foreground : AppLifecycle } @@ -37,9 +38,13 @@ 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 onActivityStopped(activity: Activity) = 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 af6ab08a3..8e395c3f3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt @@ -65,24 +65,28 @@ 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 + val weeks = remaining / 10080 + remaining %= 10080 if (weeks > 0) add(DurationPart(weeks, DurationUnit.WEEKS)) - val days = remaining / 1440; remaining %= 1440 + val days = remaining / 1440 + remaining %= 1440 if (days > 0) add(DurationPart(days, DurationUnit.DAYS)) - val hours = remaining / 60; remaining %= 60 + val hours = remaining / 60 + remaining %= 60 if (hours > 0) add(DurationPart(hours, DurationUnit.HOURS)) if (remaining > 0) add(DurationPart(remaining, DurationUnit.MINUTES)) } @@ -99,13 +103,14 @@ object DateTimeUtils { 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/GetImageOrVideoContract.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt index 8ea6a684f..4ddfa6479 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,13 @@ 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 { 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..4df184596 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt @@ -4,7 +4,6 @@ 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) { 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 2c83884e6..b7f8d3e3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt @@ -10,40 +10,41 @@ 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 { @@ -54,7 +55,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/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt index f0b7bf6e2..a95707944 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt @@ -23,24 +23,29 @@ sealed interface 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 + 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 + 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 index 08daad24d..b219e28c8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt @@ -16,12 +16,10 @@ import androidx.compose.ui.unit.Velocity * 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 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) + 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/ContentAlpha.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ContentAlpha.kt index f5ba7a2ee..e60cc6883 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 @@ -39,7 +39,7 @@ object ContentAlpha { get() = resolveAlpha( highContrastAlpha = HighContrastContentAlpha.high, - lowContrastAlpha = LowContrastContentAlpha.high + lowContrastAlpha = LowContrastContentAlpha.high, ) /** @@ -51,7 +51,7 @@ object ContentAlpha { get() = resolveAlpha( highContrastAlpha = HighContrastContentAlpha.medium, - lowContrastAlpha = LowContrastContentAlpha.medium + lowContrastAlpha = LowContrastContentAlpha.medium, ) /** @@ -63,7 +63,7 @@ object ContentAlpha { get() = resolveAlpha( highContrastAlpha = HighContrastContentAlpha.disabled, - lowContrastAlpha = LowContrastContentAlpha.disabled + lowContrastAlpha = LowContrastContentAlpha.disabled, ) /** @@ -75,10 +75,7 @@ object ContentAlpha { * for, and under what circumstances. */ @Composable - private fun resolveAlpha( - @FloatRange(from = 0.0, to = 1.0) highContrastAlpha: Float, - @FloatRange(from = 0.0, to = 1.0) lowContrastAlpha: Float - ): Float { + private fun resolveAlpha(@FloatRange(from = 0.0, to = 1.0) highContrastAlpha: Float, @FloatRange(from = 0.0, to = 1.0) lowContrastAlpha: Float): Float { val contentColor = LocalContentColor.current val isDarkTheme = isSystemInDarkTheme() return if (isDarkTheme) { 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 index f3054f18f..d41353a30 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt @@ -51,7 +51,7 @@ fun InfoBottomSheet( } } - else -> { + else -> { val sheetState = rememberUnstyledSheetState( initialDetent = SheetDetent.FullyExpanded, detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), @@ -98,14 +98,7 @@ fun InfoBottomSheet( } @Composable -private fun InfoSheetContent( - title: String, - message: String, - confirmText: String, - dismissText: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { +private fun InfoSheetContent(title: String, message: String, confirmText: String, dismissText: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() 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 index 2b29629b6..97c620d11 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -27,12 +27,12 @@ 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 @@ -69,39 +69,42 @@ fun Modifier.avoidRoundedCorners(fallback: PaddingValues): Modifier = composed { 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( + 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 + bottom = paddingBottom, ) } @@ -121,18 +124,21 @@ fun rememberRoundedCornerBottomPadding(fallback: Dp = 0.dp): Dp { val view = LocalView.current val density = LocalDensity.current - val compatInsets = ViewCompat.getRootWindowInsets(view) - ?: return fallback - val windowInsets = compatInsets.toWindowInsets() - ?: return fallback + 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, - ) + val safePadding = + maxOf( + bottomLeft?.safeBottomPadding(screenHeight) ?: 0, + bottomRight?.safeBottomPadding(screenHeight) ?: 0, + ) if (safePadding == 0) return fallback return with(density) { safePadding.toDp() } @@ -161,10 +167,12 @@ fun rememberRoundedCornerHorizontalPadding(fallback: Dp = 0.dp): PaddingValues { val view = LocalView.current val density = LocalDensity.current - val compatInsets = ViewCompat.getRootWindowInsets(view) - ?: return fallbackPadding - val windowInsets = compatInsets.toWindowInsets() - ?: return fallbackPadding + 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) @@ -182,10 +190,7 @@ fun rememberRoundedCornerHorizontalPadding(fallback: Dp = 0.dp): PaddingValues { } @RequiresApi(api = 31) -private fun RoundedCorner.calculateTopPaddingForComponent( - componentX: Int, - componentTop: Int -): Int { +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 @@ -199,10 +204,7 @@ private fun RoundedCorner.calculateTopPaddingForComponent( } @RequiresApi(api = 31) -private fun RoundedCorner.calculateBottomPaddingForComponent( - componentX: Int, - componentBottom: Int -): Int { +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 @@ -216,10 +218,7 @@ private fun RoundedCorner.calculateBottomPaddingForComponent( } @RequiresApi(api = 31) -private fun RoundedCorner.calculateStartPaddingForComponent( - componentLeft: Int, - componentY: Int -): Int { +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 @@ -233,10 +232,7 @@ private fun RoundedCorner.calculateStartPaddingForComponent( } @RequiresApi(api = 31) -private fun RoundedCorner.calculateEndPaddingForComponent( - componentRight: Int, - componentY: Int -): Int { +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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt index 79c75dcbb..73346a9cc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt @@ -37,12 +37,7 @@ import com.composables.core.rememberModalBottomSheetState import java.util.concurrent.CancellationException @Composable -fun StyledBottomSheet( - onDismiss: () -> Unit, - addBottomSpacing: Boolean = true, - dismissOnKeyboardClose: Boolean = false, - content: @Composable ColumnScope.() -> Unit, -) { +fun StyledBottomSheet(onDismiss: () -> Unit, addBottomSpacing: Boolean = true, dismissOnKeyboardClose: Boolean = false, content: @Composable ColumnScope.() -> Unit) { val sheetState = rememberModalBottomSheetState( initialDetent = SheetDetent.FullyExpanded, detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), 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..c0a2b7306 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 @@ -45,14 +45,14 @@ fun SwipeToDelete(onDelete: () -> Unit, modifier: Modifier = Modifier, enabled: val color by animateColorAsState( targetValue = when (state.dismissDirection) { SwipeToDismissBoxValue.StartToEnd, SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer - SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surfaceContainer - } + SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surfaceContainer + }, ) Box( modifier = Modifier .fillMaxSize() .background(color, CardDefaults.outlinedShape) - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp), ) { when (state.dismissDirection) { SwipeToDismissBoxValue.StartToEnd -> Icon( @@ -71,7 +71,7 @@ fun SwipeToDelete(onDelete: () -> Unit, modifier: Modifier = Modifier, enabled: 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..270a2aa72 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,17 @@ import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextDecoration @Composable -fun textLinkStyles(): TextLinkStyles { - return TextLinkStyles( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - pressedStyle = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - background = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.medium), - ), - ) -} +fun textLinkStyles(): TextLinkStyles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + 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( 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..d7ad29351 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 @@ -9,18 +9,14 @@ import kotlinx.serialization.modules.SerializersModule import okio.BufferedSink import okio.BufferedSource -class DataStoreKotlinxSerializer( - override val defaultValue: T, - private val serializer: KSerializer, - private val customSerializersModule: SerializersModule? = null, -) : OkioSerializer { - - private val json = Json { - ignoreUnknownKeys = true - customSerializersModule?.let { - serializersModule = it +class DataStoreKotlinxSerializer(override val defaultValue: T, private val serializer: KSerializer, private val customSerializersModule: SerializersModule? = null) : OkioSerializer { + private val json = + Json { + ignoreUnknownKeys = true + customSerializersModule?.let { + serializersModule = it + } } - } override suspend fun readFrom(source: BufferedSource): T = runCatching { json.decodeFromBufferedSource(serializer, source) 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..8a5071358 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 @@ -13,29 +13,30 @@ import kotlinx.serialization.KSerializer import okio.FileSystem import okio.Path.Companion.toPath -fun createDataStore( - fileName: String, - context: Context, - defaultValue: T, - serializer: KSerializer, - scope: CoroutineScope, - migrations: List> = emptyList(), -) = DataStoreFactory.create( - storage = OkioStorage( - fileSystem = FileSystem.SYSTEM, - serializer = DataStoreKotlinxSerializer( - defaultValue = defaultValue, - serializer = serializer, +fun createDataStore(fileName: String, context: Context, defaultValue: T, serializer: KSerializer, scope: CoroutineScope, migrations: List> = emptyList()) = + DataStoreFactory.create( + 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, -) + scope = scope, + migrations = migrations, + ) 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 16f229d77..87a318353 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,25 +42,30 @@ 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/BottomSheetExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt index a3c6cc00b..4c838374b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt @@ -31,18 +31,19 @@ suspend fun BottomSheetBehavior.awaitState(targetState: Int) { } 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) + 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/ChatListOperations.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt index bfd861175..43c087784 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt @@ -9,12 +9,7 @@ fun List.addAndLimit(item: ChatItem, scrollBackLength: Int, onMessageR } } -fun List.addAndLimit( - items: Collection, - scrollBackLength: Int, - onMessageRemoved: (ChatItem) -> Unit, - checkForDuplications: Boolean = false -): List = when { +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. @@ -32,14 +27,16 @@ fun List.addAndLimit( } when { excess > 0 -> sorted.subList(excess, sorted.size) - else -> sorted + else -> sorted } } - else -> toMutableList().apply { - addAll(items) - while (size > scrollBackLength) { - onMessageRemoved(removeAt(index = 0)) + else -> { + toMutableList().apply { + addAll(items) + 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..659657dd2 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 @@ -21,9 +21,7 @@ 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> { val result = mutableListOf>() 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 2913441b9..f098c0cbb 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 @@ -29,11 +29,11 @@ fun Int.normalizeColor(@ColorInt background: Int): Int { var low: Float var high: Float if (shouldLighten) { - low = hsl[2] // original lightness - high = 1f // max lightness + low = hsl[2] // original lightness + high = 1f // max lightness } else { - low = 0f // min lightness - high = hsl[2] // original lightness + low = 0f // min lightness + high = hsl[2] // original lightness } var bestL = hsl[2] 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..34e1fdd15 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 @@ -15,24 +15,22 @@ suspend fun Collection.concurrentMap(block: suspend (T) -> R): List 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 - } - - delay(interval) - yield() +fun CoroutineScope.timer(interval: Duration, action: suspend TimerScope.() -> Unit): Job = launch { + val scope = TimerScope() + + while (true) { + try { + action(scope) + } catch (ex: Exception) { + Log.e("TimerScope", Log.getStackTraceString(ex)) } + + 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 f17dd5c5a..c4c0ad995 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 @@ -22,15 +22,16 @@ fun List?.toEmoteItemsWithFront(channel: UserName?): List EmoteItem.Header(title) + emotes.map(EmoteItem::Emote).sorted() } .flatMap { it.value } @@ -49,7 +50,7 @@ inline fun measureTimeAndLog(tag: String, 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)") + else -> Log.i(tag, "Failed to load $toLoad ($time ms)") } return result 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 d056bd845..cd0ee3f7f 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 @@ -8,22 +8,17 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.transformLatest -fun mutableSharedFlowOf( - defaultValue: T, - replayValue: Int = 1, - extraBufferCapacity: Int = 0, - onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST -): MutableSharedFlow = MutableSharedFlow(replayValue, extraBufferCapacity, onBufferOverflow).apply { - tryEmit(defaultValue) -} +fun mutableSharedFlowOf(defaultValue: T, replayValue: Int = 1, extraBufferCapacity: Int = 0, 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() @@ -31,15 +26,21 @@ 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>.clear(key: UserName) = tryEmit(firstValue.apply { - put(key, 0) -}) - -fun MutableSharedFlow>.assign(key: UserName, value: T) = tryEmit(firstValue.apply { - put(key, value) -}) +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>.assign(key: UserName, value: T) = tryEmit( + firstValue.apply { + put(key, value) + }, +) 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 index 8994331d6..27f165642 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -27,17 +27,19 @@ fun List.replaceOrAddModerationMessage(moderationMessage: ModerationMe 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.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 -> { + ModerationMessage.Action.SharedBan, + -> { item.message as? PrivMessage ?: continue if (moderationMessage.targetUser != item.message.name) { continue @@ -46,7 +48,9 @@ fun List.replaceOrAddModerationMessage(moderationMessage: ModerationMe this[idx] = item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) } - else -> continue + else -> { + continue + } } } @@ -95,8 +99,11 @@ private fun MutableList.checkForStackedTimeouts(moderationMessage: Mod } when { - !moderationMessage.fromEventSource && message.fromEventSource -> Unit - moderationMessage.fromEventSource && !message.fromEventSource -> { + !moderationMessage.fromEventSource && message.fromEventSource -> { + Unit + } + + moderationMessage.fromEventSource && !message.fromEventSource -> { this[idx] = item.copy(tag = item.tag + 1, message = moderationMessage) } 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 0acb8cded..1e0403129 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 @@ -24,7 +24,7 @@ fun String.removeDuplicateWhitespace(): Pair> { if (codePoint.isWhitespace) { when { previousWhitespace -> removedSpacesPositions += totalCharCount - else -> stringBuilder.appendCodePoint(codePoint) + else -> stringBuilder.appendCodePoint(codePoint) } previousWhitespace = true @@ -39,11 +39,7 @@ fun String.removeDuplicateWhitespace(): Pair> { return stringBuilder.toString() to removedSpacesPositions } -data class CodePointAnalysis( - val supplementaryCodePointPositions: List, - val deduplicatedString: String, - val removedSpacesPositions: List, -) +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 { @@ -69,7 +65,7 @@ fun String.analyzeCodePoints(): CodePointAnalysis { if (codePoint.isWhitespace) { when { previousWhitespace -> removedSpacesPositions += totalCharCount - else -> stringBuilder.appendCodePoint(codePoint) + else -> stringBuilder.appendCodePoint(codePoint) } previousWhitespace = true } else { @@ -85,6 +81,7 @@ fun String.analyzeCodePoints(): CodePointAnalysis { } 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 @@ -184,16 +181,18 @@ 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 @@ -211,5 +210,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 index 72ab86dac..d726e4588 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt @@ -5,11 +5,9 @@ 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 { - return when { - type != SystemMessageType.Connected -> addAndLimit(type.toChatItem(), scrollBackLength, onMessageRemoved) - else -> replaceLastSystemMessageIfNecessary(scrollBackLength, onMessageRemoved, onReconnect) - } +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 { 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 7974828d7..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 @@ -5,7 +5,6 @@ import kotlin.test.assertEquals @Suppress("MaxLineLength") internal class IrcMessageTest { - // examples from https://github.com/robotty/twitch-irc-rs @Test @@ -21,7 +20,6 @@ internal class IrcMessageTest { 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 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 55a5d349d..17224bc25 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 @@ -15,7 +15,6 @@ import kotlin.test.assertEquals @ExtendWith(MockKExtension::class) internal class EmoteRepositoryTest { - @MockK lateinit var dankchatApiClient: DankChatApiClient @@ -34,11 +33,12 @@ internal class EmoteRepositoryTest { @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) @@ -48,17 +48,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 index 9e6284c25..b6ab3b445 100644 --- 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 @@ -10,25 +10,25 @@ 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() - } + 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) + 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://") @@ -37,9 +37,7 @@ class MockIrcServer : AutoCloseable { server.start() } - fun awaitConnection(timeout: Long = 5, unit: TimeUnit = TimeUnit.SECONDS): Boolean { - return connectedLatch.await(timeout, unit) - } + fun awaitConnection(timeout: Long = 5, unit: TimeUnit = TimeUnit.SECONDS): Boolean = connectedLatch.await(timeout, unit) fun sendToClient(ircLine: String) { serverSocket?.send("$ircLine\r\n") 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 index cd7dcf722..ba2cdf6ed 100644 --- 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 @@ -49,10 +49,7 @@ internal class ChatConnectionTest { httpClient.close() } - private fun createConnection( - userName: String? = null, - oAuth: String? = null, - ): ChatConnection { + 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 diff --git a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt index bbedcd7de..71b7c4adc 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt @@ -42,14 +42,14 @@ 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 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() @@ -74,17 +74,18 @@ internal class ChannelDataCoordinatorTest { startupValidationHolder.update(StartupValidation.Validated) - coordinator = ChannelDataCoordinator( - channelDataLoader = channelDataLoader, - globalDataLoader = globalDataLoader, - chatMessageRepository = chatMessageRepository, - dataRepository = dataRepository, - authDataStore = authDataStore, - preferenceStore = preferenceStore, - startupValidationHolder = startupValidationHolder, - streamDataRepository = streamDataRepository, - dispatchersProvider = dispatchersProvider, - ) + coordinator = + ChannelDataCoordinator( + channelDataLoader = channelDataLoader, + globalDataLoader = globalDataLoader, + chatMessageRepository = chatMessageRepository, + dataRepository = dataRepository, + authDataStore = authDataStore, + preferenceStore = preferenceStore, + startupValidationHolder = startupValidationHolder, + streamDataRepository = streamDataRepository, + dispatchersProvider = dispatchersProvider, + ) } @Test @@ -181,9 +182,10 @@ internal class ChannelDataCoordinatorTest { every { chatMessageRepository.clearChatLoadingFailures() } just runs coEvery { globalDataLoader.loadGlobalBTTVEmotes() } returns Result.success(Unit) - val failedState = GlobalLoadingState.Failed( - failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), - ) + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), + ) coordinator.retryDataLoading(failedState) @@ -197,9 +199,10 @@ internal class ChannelDataCoordinatorTest { 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"))), - ) + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.ChannelBTTVEmotes(channel, DisplayName("testchannel"), UserId("123")), RuntimeException("fail"))), + ) coordinator.retryDataLoading(failedState) @@ -213,9 +216,10 @@ internal class ChannelDataCoordinatorTest { every { chatMessageRepository.clearChatLoadingFailures() } just runs coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded - val failedState = GlobalLoadingState.Failed( - chatFailures = setOf(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), RuntimeException("fail"))), - ) + val failedState = + GlobalLoadingState.Failed( + chatFailures = setOf(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), RuntimeException("fail"))), + ) coordinator.retryDataLoading(failedState) @@ -228,9 +232,10 @@ internal class ChannelDataCoordinatorTest { every { chatMessageRepository.clearChatLoadingFailures() } just runs coEvery { globalDataLoader.loadGlobalBTTVEmotes() } returns Result.success(Unit) - val failedState = GlobalLoadingState.Failed( - failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), - ) + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), + ) coordinator.retryDataLoading(failedState) diff --git a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt index cf2696340..6747d1508 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt @@ -32,14 +32,14 @@ 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 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) @@ -51,23 +51,25 @@ internal class ChannelDataLoaderTest { private val testChannel = UserName("testchannel") private val testChannelId = UserId("123") - private val testChannelInfo = Channel( - id = testChannelId, - name = testChannel, - displayName = DisplayName("TestChannel"), - avatarUrl = null, - ) + 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, - ) + loader = + ChannelDataLoader( + dataRepository = dataRepository, + chatRepository = chatRepository, + chatMessageRepository = chatMessageRepository, + channelRepository = channelRepository, + getChannelsUseCase = getChannelsUseCase, + dispatchersProvider = dispatchersProvider, + ) } private fun stubAllEmotesAndBadgesSuccess() { 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 index e5ced994e..612717f15 100644 --- 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 @@ -9,17 +9,16 @@ import org.junit.jupiter.api.Test import kotlin.test.assertEquals internal class SuggestionFilteringTest { + private val provider = + SuggestionProvider( + emoteRepository = mockk(), + usersRepository = mockk(), + commandRepository = mockk(), + emoteUsageRepository = mockk(), + emojiRepository = mockk(), + ) - 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) + private fun emote(code: String, id: String = code) = GenericEmote(code = code, url = "", lowResUrl = "", id = id, scale = 1, emoteType = EmoteType.GlobalTwitchEmote) // region filterEmotes @@ -151,11 +150,12 @@ internal class SuggestionFilteringTest { @Test fun `emojis filtered by shortcode`() { - val emojis = listOf( - EmojiData("smile", "\uD83D\uDE04"), - EmojiData("wave", "\uD83D\uDC4B"), - EmojiData("smirk", "\uD83D\uDE0F"), - ) + val emojis = + listOf( + EmojiData("smile", "\uD83D\uDE04"), + EmojiData("wave", "\uD83D\uDC4B"), + EmojiData("smirk", "\uD83D\uDE0F"), + ) val result = provider.filterEmojis(emojis, "smi") assertEquals( @@ -166,10 +166,11 @@ internal class SuggestionFilteringTest { @Test fun `emojis use same scoring as emotes`() { - val emojis = listOf( - EmojiData("smirk", "\uD83D\uDE0F"), - EmojiData("smile", "\uD83D\uDE04"), - ) + val emojis = + listOf( + EmojiData("smirk", "\uD83D\uDE0F"), + EmojiData("smile", "\uD83D\uDE04"), + ) val result = provider.filterEmojis(emojis, "smi") assertEquals(2, result.size) @@ -177,10 +178,11 @@ internal class SuggestionFilteringTest { @Test fun `non-matching emojis excluded`() { - val emojis = listOf( - EmojiData("wave", "\uD83D\uDC4B"), - EmojiData("heart", "\u2764\uFE0F"), - ) + val emojis = + listOf( + EmojiData("wave", "\uD83D\uDC4B"), + EmojiData("heart", "\u2764\uFE0F"), + ) val result = provider.filterEmojis(emojis, "smi") assertEquals(emptyList(), result) 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 index 714d6e614..c8c1765ce 100644 --- 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 @@ -5,14 +5,14 @@ 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(), - ) + 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`() { 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 index eeea5027c..706d74073 100644 --- 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 @@ -6,14 +6,14 @@ 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(), - ) + private val provider = + SuggestionProvider( + emoteRepository = mockk(), + usersRepository = mockk(), + commandRepository = mockk(), + emoteUsageRepository = mockk(), + emojiRepository = mockk(), + ) @Test fun `exact full match scores lowest`() { 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 index 3c01bef4c..d6bf8536f 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt @@ -1,12 +1,11 @@ package com.flxrs.dankchat.ui.main -import com.flxrs.dankchat.ui.main.input.computeSuggestionReplacement 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") 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 index e0a1c492f..d12e7c612 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt @@ -28,7 +28,6 @@ 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() 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 37cb90152..f44e1f9b2 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt @@ -11,7 +11,6 @@ import kotlin.test.assertEquals import kotlin.test.assertNull internal class DateTimeUtilsTest { - @Test fun `formats 10 seconds correctly`() { val result = DateTimeUtils.formatSeconds(10) From 598a931a2b9551a37206766a5a253357154cfa13 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 11:03:13 +0200 Subject: [PATCH 169/349] refactor(dialog): Replace overlaying delete confirmation with inline sub view in ManageChannelsDialog --- .../ui/main/dialog/ManageChannelsDialog.kt | 302 +++++++++++------- 1 file changed, 192 insertions(+), 110 deletions(-) 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 index b037a6ada..e5b94bdca 100644 --- 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 @@ -1,14 +1,20 @@ 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 @@ -16,6 +22,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars 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 @@ -23,12 +30,14 @@ 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.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 @@ -55,6 +64,7 @@ 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 @@ -66,7 +76,12 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun ManageChannelsDialog(channels: List, onApplyChanges: (List) -> Unit, onChannelSelect: (UserName) -> Unit, onDismiss: () -> Unit) { +fun ManageChannelsDialog( + channels: List, + onApplyChanges: (List) -> Unit, + onChannelSelect: (UserName) -> Unit, + onDismiss: () -> Unit, +) { var channelToDelete by remember { mutableStateOf(null) } var editingChannel by remember { mutableStateOf(null) } @@ -79,13 +94,14 @@ fun ManageChannelsDialog(channels: List, onApplyChanges: (Lis } 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)) + 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 = { @@ -96,88 +112,103 @@ fun ManageChannelsDialog(channels: List, onApplyChanges: (Lis contentWindowInsets = { WindowInsets.statusBars }, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - 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 - }, + AnimatedContent( + targetState = channelToDelete, + transitionSpec = { + when { + targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + } + }, + label = "ManageChannelsContent", + ) { deleteTarget -> + when (deleteTarget) { + null -> { + val navBarPadding = WindowInsets.navigationBars.asPaddingValues() + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .nestedScroll(BottomSheetNestedScrollConnection), + state = lazyListState, + contentPadding = navBarPadding, ) { - 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 + 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), + ) + } } - }, - 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), ) } } } } - } - 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 }, ) } } } } - - if (channelToDelete != null) { - ConfirmationDialog( - title = stringResource(R.string.confirm_channel_removal_message), - confirmText = stringResource(R.string.confirm_channel_removal_positive_button), - onConfirm = { - val channel = channelToDelete - if (channel != null) { - localChannels.removeIf { it.channel == channel } - } - channelToDelete = null - }, - onDismiss = { channelToDelete = null }, - ) - } } @Suppress("LambdaParameterEventTrailing") @@ -193,9 +224,10 @@ private fun ChannelItem( ) { Column(modifier = modifier) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -206,19 +238,21 @@ private fun ChannelItem( ) 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) + 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), + modifier = + Modifier + .weight(1f) + .padding(horizontal = 8.dp), ) IconButton(onClick = onNavigate) { @@ -258,7 +292,10 @@ private fun ChannelItem( } @Composable -private fun InlineRenameField(channelWithRename: ChannelWithRename, onRename: (String?) -> Unit) { +private fun InlineRenameField( + channelWithRename: ChannelWithRename, + onRename: (String?) -> Unit, +) { val initialText = channelWithRename.rename?.value ?: "" var renameText by remember(channelWithRename.channel) { mutableStateOf( @@ -275,9 +312,10 @@ private fun InlineRenameField(channelWithRename: ChannelWithRename, onRename: (S } Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 56.dp, end = 8.dp, bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 56.dp, end = 8.dp, bottom = 8.dp), ) { Text( text = stringResource(R.string.edit_dialog_title), @@ -295,24 +333,27 @@ private fun InlineRenameField(channelWithRename: ChannelWithRename, onRename: (S 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), - ) + 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), + } else { + null + }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), ) TextButton( @@ -323,3 +364,44 @@ private fun InlineRenameField(channelWithRename: ChannelWithRename, onRename: (S } } } + +@Composable +private fun DeleteChannelConfirmation( + channelName: UserName, + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.confirm_channel_removal_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 = 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.confirm_channel_removal_positive_button)) + } + } + } +} From 94d4a0c05679edc58c87c0f6698105fc620c9ead Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 11:23:55 +0200 Subject: [PATCH 170/349] style: Apply Spotless + ktlint formatting to entire codebase --- .../com/flxrs/dankchat/DankChatApplication.kt | 45 +- .../com/flxrs/dankchat/data/DisplayName.kt | 4 +- .../kotlin/com/flxrs/dankchat/data/UserId.kt | 4 +- .../com/flxrs/dankchat/data/UserName.kt | 31 +- .../flxrs/dankchat/data/api/ApiException.kt | 22 +- .../flxrs/dankchat/data/api/auth/AuthApi.kt | 24 +- .../dankchat/data/api/auth/AuthApiClient.kt | 36 +- .../data/api/auth/dto/ValidateErrorDto.kt | 5 +- .../dankchat/data/api/badges/BadgesApi.kt | 4 +- .../data/api/badges/BadgesApiClient.kt | 31 +- .../data/api/badges/dto/TwitchBadgeSetDto.kt | 4 +- .../data/api/badges/dto/TwitchBadgeSetsDto.kt | 4 +- .../flxrs/dankchat/data/api/bttv/BTTVApi.kt | 4 +- .../dankchat/data/api/bttv/BTTVApiClient.kt | 31 +- .../data/api/bttv/dto/BTTVEmoteDto.kt | 6 +- .../data/api/bttv/dto/BTTVEmoteUserDto.kt | 4 +- .../data/api/bttv/dto/BTTVGlobalEmoteDto.kt | 5 +- .../dankchat/data/api/dankchat/DankChatApi.kt | 11 +- .../data/api/dankchat/DankChatApiClient.kt | 31 +- .../data/api/dankchat/dto/DankChatBadgeDto.kt | 6 +- .../data/api/eventapi/EventSubClient.kt | 85 +-- .../data/api/eventapi/EventSubClientState.kt | 4 +- .../data/api/eventapi/EventSubMessage.kt | 49 +- .../data/api/eventapi/EventSubTopic.kt | 180 +++--- .../dto/EventSubSubscriptionRequestDto.kt | 7 +- .../dto/messages/KeepAliveMessageDto.kt | 5 +- .../dto/messages/NotificationMessageDto.kt | 10 +- .../dto/messages/ReconnectMessageDto.kt | 9 +- .../dto/messages/RevocationMessageDto.kt | 9 +- .../dto/messages/WelcomeMessageDto.kt | 9 +- .../notification/AutomodMessageDto.kt | 10 +- .../com/flxrs/dankchat/data/api/ffz/FFZApi.kt | 4 +- .../dankchat/data/api/ffz/FFZApiClient.kt | 31 +- .../data/api/ffz/dto/FFZChannelDto.kt | 5 +- .../dankchat/data/api/ffz/dto/FFZEmoteDto.kt | 8 +- .../data/api/ffz/dto/FFZEmoteOwnerDto.kt | 4 +- .../data/api/ffz/dto/FFZEmoteSetDto.kt | 4 +- .../dankchat/data/api/ffz/dto/FFZGlobalDto.kt | 5 +- .../dankchat/data/api/ffz/dto/FFZRoomDto.kt | 5 +- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 593 +++++++++++------- .../dankchat/data/api/helix/HelixApiClient.kt | 590 ++++++++++------- .../data/api/helix/HelixApiException.kt | 17 +- .../api/helix/dto/AnnouncementRequestDto.kt | 5 +- .../data/api/helix/dto/BadgeSetDto.kt | 5 +- .../data/api/helix/dto/BanRequestDto.kt | 4 +- .../data/api/helix/dto/CheermoteDto.kt | 24 +- .../data/api/helix/dto/CommercialDto.kt | 6 +- .../api/helix/dto/CommercialRequestDto.kt | 5 +- .../data/api/helix/dto/DataListDto.kt | 4 +- .../data/api/helix/dto/HelixErrorDto.kt | 5 +- .../dankchat/data/api/helix/dto/MarkerDto.kt | 7 +- .../data/api/helix/dto/MarkerRequestDto.kt | 5 +- .../dankchat/data/api/helix/dto/ModVipDto.kt | 6 +- .../dankchat/data/api/helix/dto/PagedDto.kt | 5 +- .../data/api/helix/dto/PaginationDto.kt | 4 +- .../dankchat/data/api/helix/dto/RaidDto.kt | 5 +- .../data/api/helix/dto/SendChatMessageDto.kt | 11 +- .../api/helix/dto/ShieldModeRequestDto.kt | 4 +- .../data/api/helix/dto/UserBlockDto.kt | 4 +- .../data/api/helix/dto/UserFollowsDataDto.kt | 4 +- .../data/api/helix/dto/UserFollowsDto.kt | 5 +- .../data/api/helix/dto/WhisperRequestDto.kt | 4 +- .../api/recentmessages/RecentMessagesApi.kt | 9 +- .../recentmessages/RecentMessagesApiClient.kt | 23 +- .../dankchat/data/api/seventv/SevenTVApi.kt | 4 +- .../data/api/seventv/SevenTVApiClient.kt | 48 +- .../data/api/seventv/SevenTVUserDetails.kt | 6 +- .../data/api/seventv/dto/SevenTVEmoteDto.kt | 7 +- .../api/seventv/dto/SevenTVEmoteFileDto.kt | 5 +- .../api/seventv/dto/SevenTVEmoteHostDto.kt | 5 +- .../api/seventv/dto/SevenTVEmoteOwnerDto.kt | 4 +- .../api/seventv/dto/SevenTVEmoteSetDto.kt | 6 +- .../api/seventv/dto/SevenTVUserDataDto.kt | 5 +- .../data/api/seventv/dto/SevenTVUserDto.kt | 6 +- .../seventv/eventapi/SevenTVEventApiClient.kt | 46 +- .../seventv/eventapi/SevenTVEventMessage.kt | 27 +- .../api/seventv/eventapi/dto/AckMessage.kt | 4 +- .../seventv/eventapi/dto/DispatchMessage.kt | 50 +- .../eventapi/dto/EndOfStreamMessage.kt | 4 +- .../seventv/eventapi/dto/HeartbeatMessage.kt | 8 +- .../api/seventv/eventapi/dto/HelloMessage.kt | 9 +- .../seventv/eventapi/dto/ReconnectMessage.kt | 4 +- .../seventv/eventapi/dto/SubscribeRequest.kt | 28 +- .../seventv/eventapi/dto/SubscriptionType.kt | 4 +- .../eventapi/dto/UnsubscribeRequest.kt | 19 +- .../dankchat/data/api/supibot/SupibotApi.kt | 11 +- .../data/api/supibot/SupibotApiClient.kt | 44 +- .../data/api/supibot/dto/SupibotChannelDto.kt | 5 +- .../api/supibot/dto/SupibotChannelsDto.kt | 4 +- .../data/api/supibot/dto/SupibotCommandDto.kt | 5 +- .../api/supibot/dto/SupibotCommandsDto.kt | 4 +- .../api/supibot/dto/SupibotUserAliasDto.kt | 4 +- .../api/supibot/dto/SupibotUserAliasesDto.kt | 4 +- .../dankchat/data/api/upload/UploadClient.kt | 150 ++--- .../dankchat/data/api/upload/dto/UploadDto.kt | 6 +- .../flxrs/dankchat/data/auth/AuthDataStore.kt | 19 +- .../data/auth/AuthStateCoordinator.kt | 8 +- .../data/auth/StartupValidationHolder.kt | 4 +- .../com/flxrs/dankchat/data/chat/ChatItem.kt | 8 +- .../dankchat/data/debug/ApiDebugSection.kt | 4 +- .../dankchat/data/debug/AppDebugSection.kt | 12 +- .../dankchat/data/debug/AuthDebugSection.kt | 39 +- .../dankchat/data/debug/BuildDebugSection.kt | 19 +- .../data/debug/ChannelDebugSection.kt | 50 +- .../data/debug/ConnectionDebugSection.kt | 16 +- .../flxrs/dankchat/data/debug/DebugSection.kt | 11 +- .../data/debug/DebugSectionRegistry.kt | 4 +- .../dankchat/data/debug/EmoteDebugSection.kt | 81 +-- .../dankchat/data/debug/ErrorsDebugSection.kt | 32 +- .../dankchat/data/debug/RulesDebugSection.kt | 44 +- .../data/debug/SessionDebugSection.kt | 18 +- .../dankchat/data/debug/StreamDebugSection.kt | 52 +- .../data/debug/UserStateDebugSection.kt | 25 +- .../com/flxrs/dankchat/data/irc/IrcMessage.kt | 8 +- .../data/notification/NotificationData.kt | 8 +- .../data/notification/NotificationService.kt | 63 +- .../data/repo/HighlightsRepository.kt | 82 +-- .../dankchat/data/repo/IgnoresRepository.kt | 159 +++-- .../data/repo/RecentUploadsRepository.kt | 4 +- .../dankchat/data/repo/RepliesRepository.kt | 25 +- .../data/repo/UserDisplayRepository.kt | 5 +- .../dankchat/data/repo/channel/Channel.kt | 7 +- .../data/repo/channel/ChannelRepository.kt | 79 +-- .../data/repo/chat/ChatChannelProvider.kt | 4 +- .../dankchat/data/repo/chat/ChatConnector.kt | 13 +- .../data/repo/chat/ChatEventProcessor.kt | 73 ++- .../data/repo/chat/ChatLoadingFailure.kt | 5 +- .../data/repo/chat/ChatLoadingStep.kt | 4 +- .../data/repo/chat/ChatMessageRepository.kt | 59 +- .../data/repo/chat/ChatMessageSender.kt | 51 +- .../repo/chat/ChatNotificationRepository.kt | 19 +- .../dankchat/data/repo/chat/ChatRepository.kt | 16 +- .../data/repo/chat/MessageProcessor.kt | 48 +- .../data/repo/chat/RecentMessagesHandler.kt | 168 ++--- .../dankchat/data/repo/chat/UserState.kt | 9 +- .../data/repo/chat/UserStateRepository.kt | 23 +- .../data/repo/chat/UsersRepository.kt | 26 +- .../dankchat/data/repo/command/Command.kt | 4 +- .../data/repo/command/CommandRepository.kt | 103 +-- .../data/repo/command/CommandResult.kt | 17 +- .../data/repo/data/DataLoadingFailure.kt | 5 +- .../data/repo/data/DataLoadingStep.kt | 26 +- .../dankchat/data/repo/data/DataRepository.kt | 288 +++++---- .../data/repo/data/DataUpdateEventMessage.kt | 11 +- .../data/repo/emote/EmojiRepository.kt | 11 +- .../data/repo/emote/EmoteRepository.kt | 344 ++++++---- .../data/repo/emote/EmoteUsageRepository.kt | 5 +- .../flxrs/dankchat/data/repo/emote/Emotes.kt | 5 +- .../dankchat/data/repo/stream/StreamData.kt | 8 +- .../data/state/ChannelLoadingState.kt | 50 +- .../dankchat/data/state/DataLoadingState.kt | 7 +- .../dankchat/data/state/ImageUploadState.kt | 10 +- .../flxrs/dankchat/data/twitch/badge/Badge.kt | 40 +- .../dankchat/data/twitch/badge/BadgeSet.kt | 61 +- .../dankchat/data/twitch/badge/BadgeType.kt | 15 +- .../data/twitch/chat/ChatConnection.kt | 71 ++- .../data/twitch/command/CommandContext.kt | 9 +- .../data/twitch/command/TwitchCommand.kt | 4 +- .../twitch/command/TwitchCommandRepository.kt | 301 ++++++--- .../data/twitch/emote/ChatMessageEmoteType.kt | 42 +- .../data/twitch/emote/CheermoteSet.kt | 13 +- .../dankchat/data/twitch/emote/EmoteType.kt | 64 +- .../data/twitch/emote/GenericEmote.kt | 11 +- .../data/twitch/emote/ThirdPartyEmoteType.kt | 9 +- .../data/twitch/message/EmoteWithPositions.kt | 5 +- .../data/twitch/message/HighlightState.kt | 13 +- .../dankchat/data/twitch/message/Message.kt | 36 +- .../data/twitch/message/MessageThread.kt | 7 +- .../twitch/message/MessageThreadHeader.kt | 7 +- .../data/twitch/message/ModerationMessage.kt | 375 ++++++----- .../data/twitch/message/NoticeMessage.kt | 51 +- .../twitch/message/PointRedemptionMessage.kt | 9 +- .../data/twitch/message/PrivMessage.kt | 90 +-- .../dankchat/data/twitch/message/RoomState.kt | 90 +-- .../data/twitch/message/SystemMessageType.kt | 85 ++- .../data/twitch/message/UserDisplay.kt | 18 +- .../data/twitch/message/UserNoticeMessage.kt | 105 ++-- .../data/twitch/message/WhisperMessage.kt | 128 ++-- .../data/twitch/pubsub/PubSubConnection.kt | 319 +++++----- .../data/twitch/pubsub/PubSubEvent.kt | 4 +- .../data/twitch/pubsub/PubSubManager.kt | 48 +- .../data/twitch/pubsub/PubSubMessage.kt | 17 +- .../data/twitch/pubsub/PubSubTopic.kt | 19 +- .../twitch/pubsub/dto/PubSubDataMessage.kt | 5 +- .../pubsub/dto/PubSubDataObjectMessage.kt | 5 +- .../pubsub/dto/redemption/PointRedemption.kt | 5 +- .../dto/redemption/PointRedemptionData.kt | 6 +- .../dto/redemption/PointRedemptionImages.kt | 6 +- .../dto/redemption/PointRedemptionUser.kt | 6 +- .../pubsub/dto/whisper/WhisperDataBadge.kt | 5 +- .../pubsub/dto/whisper/WhisperDataEmote.kt | 6 +- .../dto/whisper/WhisperDataRecipient.kt | 7 +- .../com/flxrs/dankchat/di/ConnectionModule.kt | 14 +- .../com/flxrs/dankchat/di/CoroutineModule.kt | 13 +- .../com/flxrs/dankchat/di/DatabaseModule.kt | 9 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 191 +++--- .../dankchat/domain/ChannelDataCoordinator.kt | 7 +- .../dankchat/domain/ChannelDataLoader.kt | 88 +-- .../dankchat/domain/GetChannelsUseCase.kt | 57 +- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 47 +- .../preferences/DankChatPreferenceStore.kt | 50 +- .../dankchat/preferences/about/AboutScreen.kt | 50 +- .../appearance/AppearanceSettingsDataStore.kt | 19 +- .../appearance/AppearanceSettingsScreen.kt | 63 +- .../appearance/AppearanceSettingsState.kt | 40 +- .../appearance/AppearanceSettingsViewModel.kt | 4 +- .../dankchat/preferences/chat/ChatSettings.kt | 6 +- .../preferences/chat/ChatSettingsDataStore.kt | 58 +- .../preferences/chat/ChatSettingsScreen.kt | 56 +- .../preferences/chat/ChatSettingsState.kt | 72 ++- .../preferences/chat/ChatSettingsViewModel.kt | 170 ++--- .../chat/commands/CommandsScreen.kt | 69 +- .../chat/commands/CommandsViewModel.kt | 13 +- .../chat/userdisplay/UserDisplayEvent.kt | 14 +- .../chat/userdisplay/UserDisplayItem.kt | 40 +- .../chat/userdisplay/UserDisplayScreen.kt | 75 ++- .../chat/userdisplay/UserDisplayViewModel.kt | 57 +- .../components/CheckboxWithText.kt | 29 +- .../components/PreferenceCategory.kt | 23 +- .../preferences/components/PreferenceItem.kt | 127 ++-- .../components/PreferenceSummary.kt | 29 +- .../components/PreferenceTabRow.kt | 7 +- .../developer/DeveloperSettingsDataStore.kt | 9 +- .../developer/DeveloperSettingsScreen.kt | 159 +++-- .../developer/DeveloperSettingsState.kt | 28 +- .../developer/DeveloperSettingsViewModel.kt | 119 ++-- .../developer/customlogin/CustomLoginState.kt | 13 +- .../customlogin/CustomLoginViewModel.kt | 10 +- .../preferences/model/ChannelWithRename.kt | 5 +- .../notifications/NotificationsSettings.kt | 10 +- .../NotificationsSettingsDataStore.kt | 15 +- .../NotificationsSettingsScreen.kt | 28 +- .../NotificationsSettingsViewModel.kt | 31 +- .../highlights/HighlightEvent.kt | 14 +- .../notifications/highlights/HighlightItem.kt | 191 +++--- .../highlights/HighlightsScreen.kt | 317 ++++++---- .../highlights/HighlightsViewModel.kt | 189 +++--- .../notifications/ignores/IgnoreEvent.kt | 22 +- .../notifications/ignores/IgnoreItem.kt | 139 ++-- .../notifications/ignores/IgnoresScreen.kt | 277 ++++---- .../notifications/ignores/IgnoresViewModel.kt | 117 ++-- .../overview/OverviewSettingsScreen.kt | 72 ++- .../preferences/overview/SecretDankerMode.kt | 34 +- .../stream/StreamsSettingsDataStore.kt | 9 +- .../stream/StreamsSettingsScreen.kt | 24 +- .../stream/StreamsSettingsViewModel.kt | 43 +- .../preferences/tools/ToolsSettings.kt | 8 +- .../tools/ToolsSettingsDataStore.kt | 39 +- .../preferences/tools/ToolsSettingsScreen.kt | 83 ++- .../preferences/tools/ToolsSettingsState.kt | 28 +- .../tools/ToolsSettingsViewModel.kt | 51 +- .../tools/tts/TTSUserIgnoreListScreen.kt | 67 +- .../tools/tts/TTSUserIgnoreListViewModel.kt | 18 +- .../tools/upload/ImageUploaderScreen.kt | 105 ++-- .../tools/upload/ImageUploaderViewModel.kt | 30 +- .../preferences/tools/upload/RecentUpload.kt | 7 +- .../tools/upload/RecentUploadsViewModel.kt | 18 +- .../dankchat/ui/changelog/ChangelogScreen.kt | 7 +- .../ui/changelog/ChangelogSheetViewModel.kt | 4 +- .../dankchat/ui/changelog/ChangelogState.kt | 5 +- .../ui/changelog/DankChatChangelog.kt | 5 +- .../dankchat/ui/changelog/DankChatVersion.kt | 17 +- .../dankchat/ui/chat/AdaptiveTextColor.kt | 5 +- .../flxrs/dankchat/ui/chat/BackgroundColor.kt | 5 +- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 26 +- .../dankchat/ui/chat/ChatMessageMapper.kt | 166 +++-- .../flxrs/dankchat/ui/chat/ChatMessageText.kt | 73 +-- .../dankchat/ui/chat/ChatMessageUiState.kt | 17 +- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 172 +++-- .../dankchat/ui/chat/ChatScrollBehavior.kt | 19 +- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 12 +- .../ui/chat/EmoteAnimationCoordinator.kt | 20 +- .../dankchat/ui/chat/EmoteDrawablePainter.kt | 16 +- .../flxrs/dankchat/ui/chat/EmoteScaling.kt | 7 +- .../flxrs/dankchat/ui/chat/Linkification.kt | 6 +- .../flxrs/dankchat/ui/chat/StackedEmote.kt | 201 +++--- .../ui/chat/TextWithMeasuredInlineContent.kt | 162 ++--- .../ui/chat/emote/EmoteInfoViewModel.kt | 80 +-- .../dankchat/ui/chat/emotemenu/EmoteItem.kt | 18 +- .../ui/chat/emotemenu/EmoteMenuTabItem.kt | 5 +- .../ui/chat/mention/MentionComposable.kt | 13 +- .../chat/message/MessageOptionsViewModel.kt | 36 +- .../ui/chat/messages/AutomodMessage.kt | 256 ++++---- .../dankchat/ui/chat/messages/PrivMessage.kt | 208 +++--- .../ui/chat/messages/SystemMessages.kt | 271 ++++---- .../ui/chat/messages/WhisperAndRedemption.kt | 232 +++---- .../ui/chat/messages/common/InlineContent.kt | 29 +- .../messages/common/MessageTextBuilders.kt | 28 +- .../messages/common/MessageTextRenderer.kt | 93 +-- .../messages/common/SimpleMessageContainer.kt | 48 +- .../ui/chat/replies/RepliesComposable.kt | 9 +- .../dankchat/ui/chat/replies/RepliesState.kt | 8 +- .../dankchat/ui/chat/search/ChatItemFilter.kt | 92 +-- .../ui/chat/search/ChatSearchFilter.kt | 24 +- .../ui/chat/search/ChatSearchFilterParser.kt | 14 +- .../ui/chat/search/SearchFilterSuggestions.kt | 6 +- .../dankchat/ui/chat/suggestion/Suggestion.kt | 23 +- .../ui/chat/suggestion/SuggestionProvider.kt | 132 ++-- .../dankchat/ui/chat/user/UserPopupDialog.kt | 134 ++-- .../dankchat/ui/chat/user/UserPopupState.kt | 9 +- .../ui/chat/user/UserPopupViewModel.kt | 113 ++-- .../flxrs/dankchat/ui/login/LoginScreen.kt | 68 +- .../flxrs/dankchat/ui/login/LoginViewModel.kt | 55 +- .../flxrs/dankchat/ui/main/DraggableHandle.kt | 52 +- .../dankchat/ui/main/EmptyStateContent.kt | 14 +- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 471 ++++++++------ .../flxrs/dankchat/ui/main/MainActivity.kt | 24 +- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 83 ++- .../com/flxrs/dankchat/ui/main/MainEvent.kt | 26 +- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 413 ++++++------ .../dankchat/ui/main/MainScreenComponents.kt | 122 ++-- .../ui/main/MainScreenEventHandler.kt | 55 +- .../ui/main/MainScreenPagerContent.kt | 60 +- .../dankchat/ui/main/MainScreenViewModel.kt | 53 +- .../dankchat/ui/main/QuickActionsMenu.kt | 183 ++++-- .../dankchat/ui/main/RepeatedSendData.kt | 5 +- .../dankchat/ui/main/StreamToolbarState.kt | 15 +- .../flxrs/dankchat/ui/main/ToolbarAction.kt | 8 +- .../channel/ChannelManagementViewModel.kt | 24 +- .../ui/main/channel/ChannelPagerViewModel.kt | 22 +- .../ui/main/channel/ChannelTabUiState.kt | 15 +- .../ui/main/channel/ChannelTabViewModel.kt | 14 +- .../ui/main/dialog/AddChannelDialog.kt | 6 +- .../ui/main/dialog/ConfirmationDialog.kt | 8 +- .../ui/main/dialog/DialogStateViewModel.kt | 5 +- .../ui/main/dialog/EmoteInfoDialog.kt | 24 +- .../ui/main/dialog/MessageOptionsDialog.kt | 215 ++++--- .../ui/main/dialog/ModActionsDialog.kt | 501 +++++++++------ .../dankchat/ui/main/input/ChatBottomBar.kt | 59 +- .../dankchat/ui/main/input/ChatInputLayout.kt | 338 +++++----- .../ui/main/input/ChatInputUiState.kt | 18 +- .../ui/main/input/ChatInputViewModel.kt | 378 ++++++----- .../ui/main/input/InputActionConfig.kt | 107 ++-- .../ui/main/input/SuggestionDropdown.kt | 116 ++-- .../dankchat/ui/main/input/TourOverlay.kt | 29 +- .../dankchat/ui/main/sheet/DebugInfoSheet.kt | 41 +- .../ui/main/sheet/DebugInfoViewModel.kt | 4 +- .../flxrs/dankchat/ui/main/sheet/EmoteMenu.kt | 69 +- .../dankchat/ui/main/sheet/EmoteMenuSheet.kt | 45 +- .../ui/main/sheet/EmoteMenuViewModel.kt | 6 +- .../ui/main/sheet/FullScreenSheetOverlay.kt | 35 +- .../dankchat/ui/main/sheet/MentionSheet.kt | 112 ++-- .../ui/main/sheet/MessageHistorySheet.kt | 145 +++-- .../dankchat/ui/main/sheet/RepliesSheet.kt | 94 +-- .../ui/main/sheet/SheetNavigationState.kt | 15 +- .../ui/main/sheet/SheetNavigationViewModel.kt | 53 +- .../dankchat/ui/main/stream/StreamView.kt | 121 ++-- .../ui/main/stream/StreamViewModel.kt | 61 +- .../dankchat/ui/main/stream/StreamWebView.kt | 102 +-- .../ui/onboarding/OnboardingDataStore.kt | 19 +- .../ui/onboarding/OnboardingScreen.kt | 189 +++--- .../dankchat/ui/share/ShareUploadActivity.kt | 49 +- .../flxrs/dankchat/ui/theme/DankChatTheme.kt | 86 ++- .../dankchat/ui/tour/FeatureTourViewModel.kt | 143 +++-- .../dankchat/utils/AppLifecycleListener.kt | 18 +- .../com/flxrs/dankchat/utils/DateTimeUtils.kt | 85 +-- .../dankchat/utils/GetImageOrVideoContract.kt | 24 +- .../flxrs/dankchat/utils/IntRangeParceler.kt | 5 +- .../com/flxrs/dankchat/utils/MediaUtils.kt | 31 +- .../com/flxrs/dankchat/utils/TextResource.kt | 60 +- .../utils/compose/BottomSheetNestedScroll.kt | 18 +- .../dankchat/utils/compose/ContentAlpha.kt | 5 +- .../dankchat/utils/compose/InfoBottomSheet.kt | 61 +- .../utils/compose/RoundedCornerPadding.kt | 161 ++--- .../dankchat/utils/compose/SwipeToDelete.kt | 76 ++- .../utils/compose/buildLinkAnnotation.kt | 48 +- .../datastore/DataStoreKotlinxSerializer.kt | 18 +- .../utils/datastore/DataStoreUtils.kt | 37 +- .../dankchat/utils/datastore/Migration.kt | 61 +- .../utils/extensions/BottomSheetExtensions.kt | 12 +- .../utils/extensions/ChatListOperations.kt | 79 ++- .../utils/extensions/CollectionExtensions.kt | 15 +- .../utils/extensions/ColorExtensions.kt | 4 +- .../utils/extensions/CoroutineExtensions.kt | 43 +- .../dankchat/utils/extensions/Extensions.kt | 33 +- .../utils/extensions/FlowExtensions.kt | 44 +- .../utils/extensions/ModerationOperations.kt | 110 ++-- .../utils/extensions/StringExtensions.kt | 20 +- .../extensions/SystemMessageOperations.kt | 20 +- .../data/twitch/chat/MockIrcServer.kt | 25 +- .../twitch/chat/TwitchIrcIntegrationTest.kt | 267 ++++---- .../domain/ChannelDataCoordinatorTest.kt | 272 ++++---- .../dankchat/domain/ChannelDataLoaderTest.kt | 196 +++--- .../suggestion/SuggestionFilteringTest.kt | 5 +- .../ui/tour/FeatureTourViewModelTest.kt | 567 +++++++++-------- 385 files changed, 12591 insertions(+), 8516 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index b0f7a5a26..32e04f24d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -78,27 +78,28 @@ class DankChatApplication : } @OptIn(ExperimentalCoilApi::class) - 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}" + 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( - httpClient = { client }, - cacheStrategy = { CacheControlCacheStrategy() }, - ) - add(fetcher) - }.build() + val fetcher = + KtorNetworkFetcherFactory( + httpClient = { client }, + cacheStrategy = { CacheControlCacheStrategy() }, + ) + add(fetcher) + }.build() } 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 843b1cdda..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,7 +7,9 @@ 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 } 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 5a6b857b4..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,7 +7,9 @@ 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 } 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 49656bd15..3985f5ca8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt @@ -7,22 +7,29 @@ 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)" - } - - fun valueOrDisplayName(displayName: DisplayName): String = when { - matches(displayName) -> displayName.value - else -> this.value - } - - fun matches(other: String, ignoreCase: Boolean = true) = value.equals(other, ignoreCase) + fun formatWithDisplayName(displayName: DisplayName): String = + when { + matches(displayName) -> displayName.value + else -> "$this($displayName)" + } + + fun valueOrDisplayName(displayName: DisplayName): String = + when { + matches(displayName) -> displayName.value + else -> this.value + } + + fun matches( + other: String, + ignoreCase: Boolean = true, + ) = value.equals(other, ignoreCase) fun matches(other: UserName) = value.equals(other.value, ignoreCase = true) 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 f3ec3e0d9..176d5416c 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 @@ -11,7 +11,12 @@ import io.ktor.http.isSuccess import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -open class ApiException(open val status: HttpStatusCode, open val url: Url?, override val message: String?, override val cause: Throwable? = null) : Throwable(message, cause) { +open class ApiException( + open val status: HttpStatusCode, + open val url: Url?, + override val message: String?, + override val cause: Throwable? = null, +) : Throwable(message, cause) { override fun toString(): String = "ApiException(status=$status, url=$url, message=$message, cause=$cause)" override fun equals(other: Any?): Boolean { @@ -35,12 +40,13 @@ open class ApiException(open val status: HttpStatusCode, open val url: Url?, ove } } -fun Result.recoverNotFoundWith(default: R): Result = recoverCatching { - when { - it is ApiException && it.status == HttpStatusCode.NotFound -> default - else -> throw it +fun Result.recoverNotFoundWith(default: R): Result = + recoverCatching { + when { + it is ApiException && it.status == HttpStatusCode.NotFound -> default + else -> throw it + } } -} suspend fun HttpResponse.throwApiErrorOnFailure(json: Json): HttpResponse { if (status.isSuccess()) { @@ -56,4 +62,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 f065ec114..ff78af8dc 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 @@ -6,17 +6,23 @@ import io.ktor.client.request.forms.submitForm import io.ktor.client.request.get import io.ktor.http.parameters -class AuthApi(private val ktorClient: HttpClient) { - suspend fun validateUser(token: String) = ktorClient.get("validate") { - bearerAuth(token) - } +class AuthApi( + private val ktorClient: HttpClient, +) { + suspend fun validateUser(token: String) = + ktorClient.get("validate") { + bearerAuth(token) + } - suspend fun revokeToken(token: String, clientId: String) = ktorClient.submitForm( + suspend fun revokeToken( + token: String, + clientId: String, + ) = ktorClient.submitForm( url = "revoke", formParameters = - parameters { - append("client_id", clientId) - append("token", token) - }, + 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 f06e0c05d..c9c0e67ca 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 @@ -13,24 +13,32 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -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() - } +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 -> { - val error = json.decodeOrNull(response.bodyAsText()) - throw ApiException(status = response.status, response.request.url, error?.message) + 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) - } + suspend fun revokeToken( + token: String, + clientId: String, + ): Result = + runCatching { + authApi.revokeToken(token, clientId) + } fun validateScopes(scopes: List): Boolean = scopes.containsAll(SCOPES) 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 6b15ec668..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 @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class ValidateErrorDto(val status: Int, val message: String) +data class ValidateErrorDto( + val status: Int, + 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 ddff0abb7..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,7 +4,9 @@ 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") 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 65d013d10..fd0c07274 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,18 +9,23 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class BadgesApiClient(private val badgesApi: BadgesApi, private val json: Json) { - suspend fun getChannelBadges(channelId: UserId): Result = runCatching { - badgesApi - .getChannelBadges(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) +class BadgesApiClient( + private val badgesApi: BadgesApi, + private val json: Json, +) { + suspend fun getChannelBadges(channelId: UserId): Result = + runCatching { + badgesApi + .getChannelBadges(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) - suspend fun getGlobalBadges(): Result = runCatching { - badgesApi - .getGlobalBadges() - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) + suspend fun getGlobalBadges(): Result = + runCatching { + badgesApi + .getGlobalBadges() + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) } 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 8276097d6..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) +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 61d685bb0..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) +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 9de4f5ee3..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,7 +4,9 @@ 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") 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 9b5cfd9b6..d452e61c0 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,18 +10,23 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class BTTVApiClient(private val bttvApi: BTTVApi, private val json: Json) { - suspend fun getBTTVChannelEmotes(channelId: UserId): Result = runCatching { - bttvApi - .getChannelEmotes(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(null) +class BTTVApiClient( + private val bttvApi: BTTVApi, + private val json: Json, +) { + suspend fun getBTTVChannelEmotes(channelId: UserId): Result = + runCatching { + bttvApi + .getChannelEmotes(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(null) - suspend fun getBTTVGlobalEmotes(): Result> = runCatching { - bttvApi - .getGlobalEmotes() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getBTTVGlobalEmotes(): Result> = + runCatching { + bttvApi + .getGlobalEmotes() + .throwApiErrorOnFailure(json) + .body() + } } 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 c9d3cdf5e..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 @@ -5,4 +5,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class BTTVEmoteDto(val id: String, val code: String, val user: BTTVEmoteUserDto?) +data class BTTVEmoteDto( + val id: String, + 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 d5eb84270..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 @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class BTTVGlobalEmoteDto(@SerialName(value = "id") val id: String, @SerialName(value = "code") val code: String) +data class BTTVGlobalEmoteDto( + @SerialName(value = "id") val id: String, + @SerialName(value = "code") val code: String, +) 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 231dd943b..0e04f3baa 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 @@ -4,10 +4,13 @@ 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 getSets(ids: String) = + ktorClient.get("sets") { + parameter("id", ids) + } suspend fun getDankChatBadges() = ktorClient.get("badges") } 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 7161bc24b..4f5f4e4fa 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 @@ -8,18 +8,23 @@ 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 getUserSets(sets: List): Result> = + runCatching { + dankChatApi + .getSets(sets.joinToString(separator = ",")) + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getDankChatBadges(): Result> = runCatching { - dankChatApi - .getDankChatBadges() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getDankChatBadges(): Result> = + runCatching { + 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 7b65a058a..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 @@ -7,4 +7,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class DankChatBadgeDto(@SerialName(value = "type") val type: String, @SerialName(value = "url") val url: String, @SerialName(value = "users") val users: List) +data class DankChatBadgeDto( + @SerialName(value = "type") val type: String, + @SerialName(value = "url") val url: String, + @SerialName(value = "users") val users: List, +) 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 4048d8c11..abf3c869e 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 @@ -53,7 +53,12 @@ import kotlin.time.Duration.Companion.seconds @OptIn(DelicateCoroutinesApi::class) @Single -class EventSubClient(private val helixApiClient: HelixApiClient, private val json: Json, httpClient: HttpClient, dispatchersProvider: DispatchersProvider) { +class EventSubClient( + private val helixApiClient: HelixApiClient, + private val json: Json, + httpClient: HttpClient, + dispatchersProvider: DispatchersProvider, +) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.io) private var session: DefaultClientWebSocketSession? = null private var previousSession: DefaultClientWebSocketSession? = null @@ -74,7 +79,10 @@ class EventSubClient(private val helixApiClient: HelixApiClient, private val jso val topics = subscriptions.asStateFlow() val events = eventsChannel.receiveAsFlow().shareIn(scope = scope, started = SharingStarted.Eagerly) - fun connect(url: String = DEFAULT_URL, twitchReconnect: Boolean = false) { + fun connect( + url: String = DEFAULT_URL, + twitchReconnect: Boolean = false, + ) { Log.i(TAG, "[EventSub] starting connection, twitchReconnect=$twitchReconnect") emitSystemMessage(message = "[EventSub] connecting, twitchReconnect=$twitchReconnect") @@ -195,46 +203,47 @@ class EventSubClient(private val helixApiClient: HelixApiClient, private val jso } } - suspend fun subscribe(topic: EventSubTopic) = subscriptionMutex.withLock { - wantedSubscriptions += topic - if (subscriptions.value.any { it.topic == topic }) { - // already subscribed, nothing to do - return@withLock - } + suspend fun subscribe(topic: EventSubTopic) = + subscriptionMutex.withLock { + wantedSubscriptions += topic + if (subscriptions.value.any { it.topic == topic }) { + // already subscribed, nothing to do + return@withLock + } - // 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") - connect() - } + // 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") + connect() + } - 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 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 subscription = response.data.firstOrNull()?.id - if (subscription == null) { - Log.e(TAG, "[EventSub] subscription response did not include subscription id: $response") - return@withLock - } + val subscription = response.data.firstOrNull()?.id + if (subscription == null) { + Log.e(TAG, "[EventSub] subscription response did not include subscription id: $response") + return@withLock + } - Log.d(TAG, "[EventSub] subscribed to $topic") - emitSystemMessage(message = "[EventSub] subscribed to ${topic.shortFormatted()}") - subscriptions.update { it + SubscribedTopic(subscription, topic) } - } + Log.d(TAG, "[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 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 e4757f033..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 @@ -7,5 +7,7 @@ sealed interface 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/EventSubMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubMessage.kt index 7398825d8..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 @@ -10,14 +10,41 @@ import kotlin.time.Instant sealed interface EventSubMessage -data class SystemMessage(val message: String) : EventSubMessage - -data class ModerationAction(val id: String, val timestamp: Instant, 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 +data class SystemMessage( + val message: String, +) : EventSubMessage + +data class ModerationAction( + val id: String, + val timestamp: Instant, + 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 2b5756b33..03f49e03f 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 @@ -14,100 +14,128 @@ sealed interface EventSubTopic { fun shortFormatted(): String - data class ChannelModerate(val channel: UserName, val broadcasterId: UserId, val moderatorId: UserId) : 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, - ), - ) + data class ChannelModerate( + val channel: UserName, + val broadcasterId: UserId, + val moderatorId: UserId, + ) : 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, + ), + ) 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, - ), - ) + 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, - ), - ) + 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, - ), - ) + 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, - ), - ) + 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/EventSubSubscriptionRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionRequestDto.kt index 4828fde29..ca0b847e8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionRequestDto.kt @@ -3,4 +3,9 @@ package com.flxrs.dankchat.data.api.eventapi.dto import kotlinx.serialization.Serializable @Serializable -data class EventSubSubscriptionRequestDto(val type: EventSubSubscriptionType, val version: String, val condition: EventSubConditionDto, val transport: EventSubTransportDto) +data class EventSubSubscriptionRequestDto( + val type: EventSubSubscriptionType, + val version: String, + val condition: EventSubConditionDto, + val transport: EventSubTransportDto, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt index 64a7e2c67..413cfab08 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/KeepAliveMessageDto.kt @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("session_keepalive") -data class KeepAliveMessageDto(override val metadata: EventSubMessageMetadataDto, override val payload: EmptyPayload) : EventSubMessageDto +data class KeepAliveMessageDto( + override val metadata: EventSubMessageMetadataDto, + override val payload: EmptyPayload, +) : EventSubMessageDto 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 29179c549..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 @@ -9,10 +9,16 @@ import kotlin.time.Instant @Serializable @SerialName("notification") -data class NotificationMessageDto(override val metadata: EventSubSubscriptionMetadataDto, override val payload: NotificationMessagePayload) : EventSubMessageDto +data class NotificationMessageDto( + override val metadata: EventSubSubscriptionMetadataDto, + override val payload: NotificationMessagePayload, +) : EventSubMessageDto @Serializable -data class NotificationMessagePayload(val subscription: SubscriptionPayloadDto, val event: NotificationEventDto) : EventSubPayloadDto +data class NotificationMessagePayload( + val subscription: SubscriptionPayloadDto, + val event: NotificationEventDto, +) : EventSubPayloadDto @Serializable data class SubscriptionPayloadDto( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt index 713e67adb..8d618fe56 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/ReconnectMessageDto.kt @@ -5,7 +5,12 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("session_reconnect") -data class ReconnectMessageDto(override val metadata: EventSubMessageMetadataDto, override val payload: ReconnectMessagePayload) : EventSubMessageDto +data class ReconnectMessageDto( + override val metadata: EventSubMessageMetadataDto, + override val payload: ReconnectMessagePayload, +) : EventSubMessageDto @Serializable -data class ReconnectMessagePayload(val session: SessionPayloadDto) : EventSubPayloadDto +data class ReconnectMessagePayload( + val session: SessionPayloadDto, +) : EventSubPayloadDto diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt index 886de88d1..ec64e7965 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/RevocationMessageDto.kt @@ -5,7 +5,12 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("revocation") -data class RevocationMessageDto(override val metadata: EventSubSubscriptionMetadataDto, override val payload: RevocationMessagePayload) : EventSubMessageDto +data class RevocationMessageDto( + override val metadata: EventSubSubscriptionMetadataDto, + override val payload: RevocationMessagePayload, +) : EventSubMessageDto @Serializable -data class RevocationMessagePayload(val subscription: SubscriptionPayloadDto) : EventSubPayloadDto +data class RevocationMessagePayload( + val subscription: SubscriptionPayloadDto, +) : EventSubPayloadDto 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 6b122b01e..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 @@ -6,10 +6,15 @@ import kotlin.time.Instant @Serializable @SerialName("session_welcome") -data class WelcomeMessageDto(override val metadata: EventSubMessageMetadataDto, override val payload: WelcomeMessagePayload) : EventSubMessageDto +data class WelcomeMessageDto( + override val metadata: EventSubMessageMetadataDto, + override val payload: WelcomeMessagePayload, +) : EventSubMessageDto @Serializable -data class WelcomeMessagePayload(val session: SessionPayloadDto) : EventSubPayloadDto +data class WelcomeMessagePayload( + val session: SessionPayloadDto, +) : EventSubPayloadDto @Serializable data class SessionPayloadDto( 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 index 73f2b2d8a..02ca9343a 100644 --- 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 @@ -126,7 +126,10 @@ data class ChannelChatUserMessageUpdateDto( ) : NotificationEventDto @Serializable -data class AutomodHeldMessageDto(val text: String, val fragments: List = emptyList()) +data class AutomodHeldMessageDto( + val text: String, + val fragments: List = emptyList(), +) @Serializable data class AutomodMessageFragmentDto( @@ -135,7 +138,10 @@ data class AutomodMessageFragmentDto( ) @Serializable -data class AutomodReasonDto(val category: String, val level: Int) +data class AutomodReasonDto( + val category: String, + val level: Int, +) @Serializable data class BlockedTermReasonDto( 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 b41ac5d0d..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,7 +4,9 @@ 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") 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 fa78032a3..93509150b 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,18 +10,23 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class FFZApiClient(private val ffzApi: FFZApi, private val json: Json) { - suspend fun getFFZChannelEmotes(channelId: UserId): Result = runCatching { - ffzApi - .getChannelEmotes(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(null) +class FFZApiClient( + private val ffzApi: FFZApi, + private val json: Json, +) { + suspend fun getFFZChannelEmotes(channelId: UserId): Result = + runCatching { + ffzApi + .getChannelEmotes(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(null) - suspend fun getFFZGlobalEmotes(): Result = runCatching { - ffzApi - .getGlobalEmotes() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getFFZGlobalEmotes(): Result = + runCatching { + 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 b402dfa5e..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 @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZChannelDto(@SerialName(value = "room") val room: FFZRoomDto, @SerialName(value = "sets") val sets: Map) +data class FFZChannelDto( + @SerialName(value = "room") val room: FFZRoomDto, + @SerialName(value = "sets") val sets: Map, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt index e319e49ee..cd70fbb06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteDto.kt @@ -5,4 +5,10 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZEmoteDto(val urls: Map, val animated: Map?, val name: String, val id: Int, val owner: FFZEmoteOwnerDto?) +data class FFZEmoteDto( + val urls: Map, + val animated: Map?, + val name: String, + val id: Int, + val owner: FFZEmoteOwnerDto?, +) 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 33308395f..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) +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 acf45c12b..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 @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZGlobalDto(@SerialName(value = "default_sets") val defaultSets: List, @SerialName(value = "sets") val sets: Map) +data class FFZGlobalDto( + @SerialName(value = "default_sets") val defaultSets: List, + @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 537b3581c..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 @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZRoomDto(@SerialName(value = "mod_urls") val modBadgeUrls: Map?, @SerialName(value = "vip_badge") val vipBadgeUrls: Map?) +data class FFZRoomDto( + @SerialName(value = "mod_urls") val modBadgeUrls: Map?, + @SerialName(value = "vip_badge") val vipBadgeUrls: Map?, +) 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 d553bcae8..69f89a81e 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 @@ -28,293 +28,406 @@ import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType -class HelixApi(private val ktorClient: HttpClient, private val authDataStore: AuthDataStore, private val startupValidationHolder: StartupValidationHolder) { +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 = getValidToken() ?: return null - bearerAuth(oAuth) - logins.forEach { - parameter("login", it) + suspend fun getUsersByName(logins: List): HttpResponse? = + ktorClient.get("users") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + logins.forEach { + parameter("login", it) + } } - } - suspend fun getUsersByIds(ids: List): HttpResponse? = ktorClient.get("users") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - ids.forEach { - parameter("id", it) + suspend fun getUsersByIds(ids: List): HttpResponse? = + ktorClient.get("users") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - if (targetUserId != null) { - parameter("user_id", targetUserId) - } - if (first != null) { - parameter("first", first) + 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) { + parameter("user_id", targetUserId) + } + if (first != null) { + parameter("first", first) + } + if (after != null) { + parameter("after", after) + } } - if (after != null) { - parameter("after", after) - } - } - suspend fun getStreams(channels: List): HttpResponse? = ktorClient.get("streams") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - channels.forEach { - parameter("user_login", it) + suspend fun getStreams(channels: List): HttpResponse? = + ktorClient.get("streams") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", userId) - parameter("first", first) - if (after != null) { - parameter("after", after) + 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) + if (after != null) { + parameter("after", after) + } } - } - suspend fun putUserBlock(targetUserId: UserId): HttpResponse? = ktorClient.put("users/blocks") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("target_user_id", targetUserId) - } + suspend fun putUserBlock(targetUserId: UserId): HttpResponse? = + ktorClient.put("users/blocks") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("target_user_id", targetUserId) + } - suspend fun deleteUserBlock(targetUserId: UserId): HttpResponse? = ktorClient.delete("users/blocks") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("target_user_id", targetUserId) - } + suspend fun deleteUserBlock(targetUserId: UserId): HttpResponse? = + ktorClient.delete("users/blocks") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + 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) + contentType(ContentType.Application.Json) + setBody(request) + } - 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) - if (after != null) { - parameter("after", after) + 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) + if (after != null) { + parameter("after", after) + } } - } - 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 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 = 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 = 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("from_user_id", fromUserId) - parameter("to_user_id", toUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + 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) + contentType(ContentType.Application.Json) + setBody(request) + } - 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) - if (after != null) { - parameter("after", after) + 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) + if (after != null) { + parameter("after", after) + } } - } - 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 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 = 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 = 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + 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) + contentType(ContentType.Application.Json) + setBody(request) + } - 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 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - if (messageId != null) { - parameter("message_id", messageId) + 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) + if (messageId != null) { + parameter("message_id", messageId) + } } - } - 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 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 = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - setBody(request) - } + suspend fun postMarker(request: MarkerRequestDto): HttpResponse? = + ktorClient.post("streams/markers") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - setBody(request) - } + suspend fun postCommercial(request: CommercialRequestDto): HttpResponse? = + ktorClient.post("channels/commercial") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("from_broadcaster_id", broadcasterUserId) - parameter("to_broadcaster_id", targetUserId) - } + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - } + suspend fun deleteRaid(broadcasterUserId: UserId): HttpResponse? = + ktorClient.delete("raids") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + 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) + contentType(ContentType.Application.Json) + setBody(request) + } - suspend fun getGlobalBadges(): HttpResponse? = ktorClient.get("chat/badges/global") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - } + suspend fun getGlobalBadges(): HttpResponse? = + ktorClient.get("chat/badges/global") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + contentType(ContentType.Application.Json) + } - suspend fun getChannelBadges(broadcasterUserId: UserId): HttpResponse? = ktorClient.get("chat/badges") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - contentType(ContentType.Application.Json) - } + suspend fun getChannelBadges(broadcasterUserId: UserId): HttpResponse? = + ktorClient.get("chat/badges") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + contentType(ContentType.Application.Json) + } - 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 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 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) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - } + 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) + parameter("moderator_id", moderatorUserId) + contentType(ContentType.Application.Json) + } - 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 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) - contentType(ContentType.Application.Json) - setBody(request) - } + 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) + contentType(ContentType.Application.Json) + setBody(request) + } - suspend fun postEventSubSubscription(eventSubSubscriptionRequestDto: EventSubSubscriptionRequestDto): HttpResponse? = ktorClient.post("eventsub/subscriptions") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - setBody(eventSubSubscriptionRequestDto) - } + suspend fun postEventSubSubscription(eventSubSubscriptionRequestDto: EventSubSubscriptionRequestDto): HttpResponse? = + ktorClient.post("eventsub/subscriptions") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("id", id) - } + suspend fun deleteEventSubSubscription(id: String): HttpResponse? = + ktorClient.delete("eventsub/subscriptions") { + 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 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 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) - } + 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 05412e3e5..31ca3a2c9 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 @@ -44,293 +44,411 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -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) - .throwHelixApiErrorOnFailure() - .body>() - .data +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) + .throwHelixApiErrorOnFailure() + .body>() + .data + } } - } - suspend fun getUsersByIds(ids: List): Result> = runCatching { - ids.chunked(DEFAULT_PAGE_SIZE).flatMap { - helixApi - .getUsersByIds(it) - .throwHelixApiErrorOnFailure() - .body>() - .data + suspend fun getUsersByIds(ids: List): Result> = + runCatching { + ids.chunked(DEFAULT_PAGE_SIZE).flatMap { + helixApi + .getUsersByIds(it) + .throwHelixApiErrorOnFailure() + .body>() + .data + } } - } - suspend fun getUser(userId: UserId): Result = getUsersByIds(listOf(userId)) - .mapCatching { it.first() } + suspend fun getUser(userId: UserId): Result = + getUsersByIds(listOf(userId)) + .mapCatching { it.first() } - suspend fun getUserByName(name: UserName): Result = getUsersByNames(listOf(name)) - .mapCatching { it.first() } + suspend fun getUserByName(name: UserName): Result = + getUsersByNames(listOf(name)) + .mapCatching { it.first() } - suspend fun getUserIdByName(name: UserName): Result = getUserByName(name) - .mapCatching { it.id } + suspend fun getUserIdByName(name: UserName): Result = + getUserByName(name) + .mapCatching { it.id } - 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 { + suspend fun getChannelFollowers( + broadcastUserId: UserId, + targetUserId: UserId, + ): Result = + runCatching { helixApi - .getStreams(it) + .getChannelFollowers(broadcastUserId, targetUserId) .throwHelixApiErrorOnFailure() - .body>() - .data + .body() } - } - suspend fun getUserBlocks(userId: UserId, maxUserBlocksToFetch: Int = 500): Result> = runCatching { - pageUntil(maxUserBlocksToFetch) { cursor -> - helixApi.getUserBlocks(userId, DEFAULT_PAGE_SIZE, cursor) + suspend fun getStreams(channels: List): Result> = + runCatching { + channels.chunked(DEFAULT_PAGE_SIZE).flatMap { + helixApi + .getStreams(it) + .throwHelixApiErrorOnFailure() + .body>() + .data + } } - } - suspend fun blockUser(targetUserId: UserId): Result = runCatching { - helixApi - .putUserBlock(targetUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun getUserBlocks( + userId: UserId, + maxUserBlocksToFetch: Int = 500, + ): Result> = + runCatching { + pageUntil(maxUserBlocksToFetch) { cursor -> + helixApi.getUserBlocks(userId, DEFAULT_PAGE_SIZE, cursor) + } + } - suspend fun unblockUser(targetUserId: UserId): Result = runCatching { - helixApi - .deleteUserBlock(targetUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun blockUser(targetUserId: UserId): Result = + runCatching { + helixApi + .putUserBlock(targetUserId) + .throwHelixApiErrorOnFailure() + } - suspend fun postAnnouncement(broadcastUserId: UserId, moderatorUserId: UserId, request: AnnouncementRequestDto): Result = runCatching { - helixApi - .postAnnouncement(broadcastUserId, moderatorUserId, request) - .throwHelixApiErrorOnFailure() - } + suspend fun unblockUser(targetUserId: UserId): Result = + runCatching { + helixApi + .deleteUserBlock(targetUserId) + .throwHelixApiErrorOnFailure() + } - suspend fun postWhisper(fromUserId: UserId, toUserId: UserId, request: WhisperRequestDto): Result = runCatching { - helixApi - .postWhisper(fromUserId, toUserId, request) - .throwHelixApiErrorOnFailure() - } + suspend fun postAnnouncement( + broadcastUserId: UserId, + moderatorUserId: UserId, + request: AnnouncementRequestDto, + ): Result = + runCatching { + helixApi + .postAnnouncement(broadcastUserId, moderatorUserId, request) + .throwHelixApiErrorOnFailure() + } - suspend fun getModerators(broadcastUserId: UserId, maxModeratorsToFetch: Int = 500): Result> = runCatching { - pageUntil(maxModeratorsToFetch) { cursor -> - helixApi.getModerators(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) + suspend fun postWhisper( + fromUserId: UserId, + toUserId: UserId, + request: WhisperRequestDto, + ): Result = + runCatching { + helixApi + .postWhisper(fromUserId, toUserId, request) + .throwHelixApiErrorOnFailure() } - } - suspend fun postModerator(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi - .postModerator(broadcastUserId, userId) - .throwHelixApiErrorOnFailure() - } + suspend fun getModerators( + broadcastUserId: UserId, + maxModeratorsToFetch: Int = 500, + ): Result> = + runCatching { + pageUntil(maxModeratorsToFetch) { cursor -> + helixApi.getModerators(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) + } + } - suspend fun deleteModerator(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi - .deleteModerator(broadcastUserId, userId) - .throwHelixApiErrorOnFailure() - } + suspend fun postModerator( + broadcastUserId: UserId, + userId: UserId, + ): Result = + runCatching { + helixApi + .postModerator(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() + } - suspend fun getVips(broadcastUserId: UserId, maxVipsToFetch: Int = 500): Result> = runCatching { - pageUntil(maxVipsToFetch) { cursor -> - helixApi.getVips(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) + suspend fun deleteModerator( + broadcastUserId: UserId, + userId: UserId, + ): Result = + runCatching { + helixApi + .deleteModerator(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() } - } - suspend fun postVip(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi - .postVip(broadcastUserId, userId) - .throwHelixApiErrorOnFailure() - } + suspend fun getVips( + broadcastUserId: UserId, + maxVipsToFetch: Int = 500, + ): Result> = + runCatching { + pageUntil(maxVipsToFetch) { cursor -> + helixApi.getVips(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) + } + } - suspend fun deleteVip(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi - .deleteVip(broadcastUserId, userId) - .throwHelixApiErrorOnFailure() - } + suspend fun postVip( + broadcastUserId: UserId, + userId: UserId, + ): Result = + runCatching { + helixApi + .postVip(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() + } - suspend fun postBan(broadcastUserId: UserId, moderatorUserId: UserId, requestDto: BanRequestDto): Result = runCatching { - helixApi - .postBan(broadcastUserId, moderatorUserId, requestDto) - .throwHelixApiErrorOnFailure() - } + suspend fun deleteVip( + broadcastUserId: UserId, + userId: UserId, + ): Result = + runCatching { + helixApi + .deleteVip(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() + } - suspend fun deleteBan(broadcastUserId: UserId, moderatorUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi - .deleteBan(broadcastUserId, moderatorUserId, targetUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun postBan( + broadcastUserId: UserId, + moderatorUserId: UserId, + requestDto: BanRequestDto, + ): Result = + runCatching { + helixApi + .postBan(broadcastUserId, moderatorUserId, requestDto) + .throwHelixApiErrorOnFailure() + } - suspend fun deleteMessages(broadcastUserId: UserId, moderatorUserId: UserId, messageId: String? = null): Result = runCatching { - helixApi - .deleteMessages(broadcastUserId, moderatorUserId, messageId) - .throwHelixApiErrorOnFailure() - } + suspend fun deleteBan( + broadcastUserId: UserId, + moderatorUserId: UserId, + targetUserId: UserId, + ): Result = + runCatching { + helixApi + .deleteBan(broadcastUserId, moderatorUserId, targetUserId) + .throwHelixApiErrorOnFailure() + } - suspend fun putUserChatColor(targetUserId: UserId, color: String): Result = runCatching { - helixApi - .putUserChatColor(targetUserId, color) - .throwHelixApiErrorOnFailure() - } + suspend fun deleteMessages( + broadcastUserId: UserId, + moderatorUserId: UserId, + messageId: String? = null, + ): Result = + runCatching { + helixApi + .deleteMessages(broadcastUserId, moderatorUserId, messageId) + .throwHelixApiErrorOnFailure() + } - suspend fun postMarker(requestDto: MarkerRequestDto): Result = runCatching { - helixApi - .postMarker(requestDto) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun putUserChatColor( + targetUserId: UserId, + color: String, + ): Result = + runCatching { + helixApi + .putUserChatColor(targetUserId, color) + .throwHelixApiErrorOnFailure() + } - suspend fun postCommercial(request: CommercialRequestDto): Result = runCatching { - helixApi - .postCommercial(request) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun postMarker(requestDto: MarkerRequestDto): Result = + runCatching { + helixApi + .postMarker(requestDto) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun postRaid(broadcastUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi - .postRaid(broadcastUserId, targetUserId) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun postCommercial(request: CommercialRequestDto): Result = + runCatching { + helixApi + .postCommercial(request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun deleteRaid(broadcastUserId: UserId): Result = runCatching { - helixApi - .deleteRaid(broadcastUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun postRaid( + broadcastUserId: UserId, + targetUserId: UserId, + ): Result = + runCatching { + helixApi + .postRaid(broadcastUserId, targetUserId) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun patchChatSettings(broadcastUserId: UserId, moderatorUserId: UserId, request: ChatSettingsRequestDto): Result = runCatching { - helixApi - .patchChatSettings(broadcastUserId, moderatorUserId, request) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun deleteRaid(broadcastUserId: UserId): Result = + runCatching { + helixApi + .deleteRaid(broadcastUserId) + .throwHelixApiErrorOnFailure() + } - suspend fun getGlobalBadges(): Result> = runCatching { - helixApi - .getGlobalBadges() - .throwHelixApiErrorOnFailure() - .body>() - .data - } + suspend fun patchChatSettings( + broadcastUserId: UserId, + moderatorUserId: UserId, + request: ChatSettingsRequestDto, + ): Result = + runCatching { + helixApi + .patchChatSettings(broadcastUserId, moderatorUserId, request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun getChannelBadges(broadcastUserId: UserId): Result> = runCatching { - helixApi - .getChannelBadges(broadcastUserId) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + suspend fun getGlobalBadges(): Result> = + runCatching { + helixApi + .getGlobalBadges() + .throwHelixApiErrorOnFailure() + .body>() + .data + } - suspend fun getCheermotes(broadcasterId: UserId): Result> = runCatching { - helixApi - .getCheermotes(broadcasterId) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + suspend fun getChannelBadges(broadcastUserId: UserId): Result> = + runCatching { + helixApi + .getChannelBadges(broadcastUserId) + .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 getCheermotes(broadcasterId: UserId): Result> = + runCatching { + helixApi + .getCheermotes(broadcasterId) + .throwHelixApiErrorOnFailure() + .body>() + .data + } - suspend fun postShoutout(broadcastUserId: UserId, targetUserId: UserId, moderatorUserId: UserId): Result = runCatching { - helixApi - .postShoutout(broadcastUserId, targetUserId, moderatorUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun manageAutomodMessage( + userId: UserId, + msgId: String, + action: String, + ): Result = + runCatching { + helixApi + .postManageAutomodMessage(ManageAutomodMessageRequestDto(userId = userId, msgId = msgId, action = action)) + .throwHelixApiErrorOnFailure() + } - suspend fun getShieldMode(broadcastUserId: UserId, moderatorUserId: UserId): Result = runCatching { - helixApi - .getShieldMode(broadcastUserId, moderatorUserId) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun postShoutout( + broadcastUserId: UserId, + targetUserId: UserId, + moderatorUserId: UserId, + ): Result = + runCatching { + helixApi + .postShoutout(broadcastUserId, targetUserId, moderatorUserId) + .throwHelixApiErrorOnFailure() + } - suspend fun putShieldMode(broadcastUserId: UserId, moderatorUserId: UserId, request: ShieldModeRequestDto): Result = runCatching { - helixApi - .putShieldMode(broadcastUserId, moderatorUserId, request) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun getShieldMode( + broadcastUserId: UserId, + moderatorUserId: UserId, + ): Result = + runCatching { + helixApi + .getShieldMode(broadcastUserId, moderatorUserId) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun postEventSubSubscription(request: EventSubSubscriptionRequestDto): Result = runCatching { - helixApi - .postEventSubSubscription(request) - .throwHelixApiErrorOnFailure() - .body() - } + suspend fun putShieldMode( + broadcastUserId: UserId, + moderatorUserId: UserId, + request: ShieldModeRequestDto, + ): Result = + runCatching { + helixApi + .putShieldMode(broadcastUserId, moderatorUserId, request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun deleteEventSubSubscription(id: String): Result = runCatching { - helixApi - .deleteEventSubSubscription(id) - .throwHelixApiErrorOnFailure() - } + suspend fun postEventSubSubscription(request: EventSubSubscriptionRequestDto): Result = + runCatching { + helixApi + .postEventSubSubscription(request) + .throwHelixApiErrorOnFailure() + .body() + } - fun getUserEmotesFlow(userId: UserId): Flow> = pageAsFlow(MAX_USER_EMOTES) { cursor -> - helixApi.getUserEmotes(userId, cursor) - } + suspend fun deleteEventSubSubscription(id: String): Result = + runCatching { + helixApi + .deleteEventSubSubscription(id) + .throwHelixApiErrorOnFailure() + } - suspend fun getChannelEmotes(broadcasterId: UserId): Result> = runCatching { - helixApi - .getChannelEmotes(broadcasterId) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + fun getUserEmotesFlow(userId: UserId): Flow> = + pageAsFlow(MAX_USER_EMOTES) { cursor -> + helixApi.getUserEmotes(userId, cursor) + } - suspend fun postChatMessage(request: SendChatMessageRequestDto): Result = runCatching { - helixApi - .postChatMessage(request) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun getChannelEmotes(broadcasterId: UserId): Result> = + runCatching { + helixApi + .getChannelEmotes(broadcasterId) + .throwHelixApiErrorOnFailure() + .body>() + .data + } - private inline fun pageAsFlow(amountToFetch: Int, crossinline request: suspend (cursor: String?) -> HttpResponse?): Flow> = flow { - val initialPage = - request(null) + suspend fun postChatMessage(request: SendChatMessageRequestDto): Result = + runCatching { + helixApi + .postChatMessage(request) .throwHelixApiErrorOnFailure() - .body>() - emit(initialPage.data) - var cursor = initialPage.pagination.cursor - var count = initialPage.data.size - while (cursor != null && count < amountToFetch) { - val result = - request(cursor) + .body>() + .data + .first() + } + + private inline fun pageAsFlow( + amountToFetch: Int, + crossinline request: suspend (cursor: String?) -> HttpResponse?, + ): Flow> = + flow { + val initialPage = + request(null) .throwHelixApiErrorOnFailure() .body>() - emit(result.data) - count += result.data.size - cursor = result.pagination.cursor + emit(initialPage.data) + var cursor = initialPage.pagination.cursor + 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 + } } - } - private suspend inline fun pageUntil(amountToFetch: Int, request: (cursor: String?) -> HttpResponse?): List { + private suspend inline fun pageUntil( + amountToFetch: Int, + request: (cursor: String?) -> HttpResponse?, + ): List { val initialPage = request(null) .throwHelixApiErrorOnFailure() 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 e08ac3ab4..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 @@ -4,8 +4,13 @@ import com.flxrs.dankchat.data.api.ApiException import io.ktor.http.HttpStatusCode import io.ktor.http.Url -data class HelixApiException(val error: HelixError, override val status: HttpStatusCode, override val url: Url?, override val message: String? = null, override val cause: Throwable? = null) : - ApiException(status, url, message, cause) +data class HelixApiException( + val error: HelixError, + override val status: HttpStatusCode, + override val url: Url?, + override val message: String? = null, + override val cause: Throwable? = null, +) : ApiException(status, url, message, cause) sealed interface HelixError { data object MissingScopes : HelixError @@ -44,7 +49,9 @@ sealed interface HelixError { data object InvalidColor : HelixError - data class MarkerError(val message: String?) : HelixError + data class MarkerError( + val message: String?, + ) : HelixError data object CommercialRateLimited : HelixError @@ -56,7 +63,9 @@ sealed interface HelixError { data object NoRaidPending : HelixError - data class NotInRange(val validRange: IntRange?) : HelixError + data class NotInRange( + val validRange: IntRange?, + ) : HelixError data object Forwarded : HelixError 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/CheermoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CheermoteDto.kt index 5f7cf820c..786f0fcef 100644 --- 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 @@ -6,16 +6,32 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class CheermoteSetDto(val prefix: String, val tiers: List, val type: String, val order: Int) +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) +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) +data class CheermoteTierImagesDto( + val dark: CheermoteThemeImagesDto, + val light: CheermoteThemeImagesDto, +) @Keep @Serializable -data class CheermoteThemeImagesDto(val animated: Map, val static: Map) +data class CheermoteThemeImagesDto( + val animated: Map, + val static: Map, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt index 816b4c450..ed037c94b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialDto.kt @@ -6,4 +6,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class CommercialDto(val length: Int, val message: String?, @SerialName("retry_after") val retryAfter: Int) +data class CommercialDto( + val length: Int, + val message: String?, + @SerialName("retry_after") val retryAfter: Int, +) 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 14a53c233..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 @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class HelixErrorDto(val status: Int, val message: String) +data class HelixErrorDto( + val status: Int, + val message: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt index 980a08a3c..b019c686d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerDto.kt @@ -6,4 +6,9 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class MarkerDto(val id: String, val description: String?, @SerialName("created_at") val createdAt: String, @SerialName("position_seconds") val positionSeconds: Int) +data class MarkerDto( + val id: String, + val description: String?, + @SerialName("created_at") val createdAt: String, + @SerialName("position_seconds") val positionSeconds: Int, +) 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 946c7c1ac..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 @@ -7,4 +7,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class MarkerRequestDto(@SerialName("user_id") val userId: UserId, val description: String?) +data class MarkerRequestDto( + @SerialName("user_id") val userId: UserId, + val description: String?, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt index 7aaa829c4..29fbd28a4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ModVipDto.kt @@ -9,4 +9,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class ModVipDto(@SerialName("user_id") val userId: UserId, @SerialName("user_login") val userLogin: UserName, @SerialName("user_name") val userName: DisplayName) +data class ModVipDto( + @SerialName("user_id") val userId: UserId, + @SerialName("user_login") val userLogin: UserName, + @SerialName("user_name") val userName: DisplayName, +) 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 index 52a0f2d98..3a1aa5e89 100644 --- 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 @@ -13,7 +13,14 @@ data class SendChatMessageRequestDto( ) @Serializable -data class SendChatMessageResponseDto(@SerialName("message_id") val messageId: String, @SerialName("is_sent") val isSent: Boolean, @SerialName("drop_reason") val dropReason: DropReasonDto? = null) +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) +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 528f57ecb..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 @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class ShieldModeRequestDto(@SerialName("is_active") val isActive: Boolean) +data class ShieldModeRequestDto( + @SerialName("is_active") val isActive: Boolean, +) 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 5803f9a01..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 @@ -7,4 +7,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class UserBlockDto(@SerialName(value = "user_id") val id: UserId) +data class UserBlockDto( + @SerialName(value = "user_id") val id: UserId, +) 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 fd0ee55fa..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 @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class UserFollowsDataDto(@SerialName(value = "followed_at") val followedAt: String) +data class UserFollowsDataDto( + @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 76b834c35..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 @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class UserFollowsDto(@SerialName(value = "total") val total: Int, @SerialName(value = "data") val data: List) +data class UserFollowsDto( + @SerialName(value = "total") val total: Int, + @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 b292b5f85..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,8 +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) } } 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 75de80918..2c41f5a81 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 @@ -12,14 +12,21 @@ import kotlinx.coroutines.flow.first import org.koin.core.annotation.Single @Single -class RecentMessagesApiClient(private val recentMessagesApi: RecentMessagesApi, private val chatSettingsDataStore: ChatSettingsDataStore) { - suspend fun getRecentMessages(channel: UserName, messageLimit: Int? = null): Result = runCatching { - val limit = messageLimit ?: chatSettingsDataStore.settings.first().scrollbackLength - recentMessagesApi - .getRecentMessages(channel, limit) - .throwRecentMessagesErrorOnFailure() - .body() - } +class RecentMessagesApiClient( + private val recentMessagesApi: RecentMessagesApi, + private val chatSettingsDataStore: ChatSettingsDataStore, +) { + suspend fun getRecentMessages( + channel: UserName, + messageLimit: Int? = null, + ): Result = + runCatching { + val limit = messageLimit ?: chatSettingsDataStore.settings.first().scrollbackLength + recentMessagesApi + .getRecentMessages(channel, limit) + .throwRecentMessagesErrorOnFailure() + .body() + } private suspend fun HttpResponse.throwRecentMessagesErrorOnFailure(): HttpResponse { if (status.isSuccess()) { 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 9bf21acff..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,7 +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 fbc977828..6ea6c6b43 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,27 +11,33 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class SevenTVApiClient(private val sevenTVApi: SevenTVApi, private val json: Json) { - suspend fun getSevenTVChannelEmotes(channelId: UserId): Result = runCatching { - sevenTVApi - .getChannelEmotes(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(default = null) +class SevenTVApiClient( + private val sevenTVApi: SevenTVApi, + private val json: Json, +) { + suspend fun getSevenTVChannelEmotes(channelId: UserId): Result = + runCatching { + sevenTVApi + .getChannelEmotes(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(default = null) - suspend fun getSevenTVEmoteSet(emoteSetId: String): Result = runCatching { - sevenTVApi - .getEmoteSet(emoteSetId) - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getSevenTVEmoteSet(emoteSetId: String): Result = + runCatching { + sevenTVApi + .getEmoteSet(emoteSetId) + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getSevenTVGlobalEmotes(): Result> = runCatching { - sevenTVApi - .getGlobalEmotes() - .throwApiErrorOnFailure(json) - .body() - .emotes - .orEmpty() - } + suspend fun getSevenTVGlobalEmotes(): Result> = + runCatching { + sevenTVApi + .getGlobalEmotes() + .throwApiErrorOnFailure(json) + .body() + .emotes + .orEmpty() + } } 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/SevenTVEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDto.kt index ee02029d8..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 @@ -5,7 +5,12 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteDto(val id: String, val name: String, val flags: Long, val data: SevenTVEmoteDataDto?) { +data class SevenTVEmoteDto( + val id: String, + val name: String, + val flags: Long, + val data: SevenTVEmoteDataDto?, +) { val isZeroWidth get() = (ZERO_WIDTH_FLAG and flags) == ZERO_WIDTH_FLAG companion object { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt index 8fd3d5602..5ae8893ad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteFileDto.kt @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteFileDto(val name: String, val format: String) +data class SevenTVEmoteFileDto( + val name: String, + val format: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt index 8f949e168..6b3cfc9e3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteHostDto.kt @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteHostDto(val url: String, val files: List) +data class SevenTVEmoteHostDto( + val url: String, + val files: List, +) 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 22060c42c..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 @@ -5,4 +5,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteSetDto(val id: String, val name: String, val emotes: List?) +data class SevenTVEmoteSetDto( + val id: String, + val name: String, + val emotes: List?, +) 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 a7bc4e261..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 @@ -6,4 +6,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVUserDto(val id: String, val user: SevenTVUserDataDto, @SerialName("emote_set") val emoteSet: SevenTVEmoteSetDto?) +data class SevenTVUserDto( + val id: String, + val user: SevenTVUserDataDto, + @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 58e6d3c45..461f0b82c 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 @@ -211,28 +211,37 @@ class SevenTVEventApiClient( } } - private fun setupHeartBeatInterval(): Job = scope.launch { - delay(heartBeatInterval) - timer(heartBeatInterval) { - val webSocket = socket - if (webSocket == null || System.currentTimeMillis() - lastHeartBeat > 3 * heartBeatInterval.inWholeMilliseconds) { - cancel() - reconnect() - return@timer + private fun setupHeartBeatInterval(): Job = + scope.launch { + delay(heartBeatInterval) + timer(heartBeatInterval) { + val webSocket = socket + if (webSocket == null || System.currentTimeMillis() - lastHeartBeat > 3 * heartBeatInterval.inWholeMilliseconds) { + cancel() + reconnect() + return@timer + } } } - } private inner class EventApiWebSocketListener : WebSocketListener() { private var heartBeatJob: Job? = null - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { Log.d(TAG, "[7TV Event-Api] connection closed") connected = false heartBeatJob?.cancel() } - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + 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..") connected = false @@ -242,14 +251,20 @@ 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") } - override fun onMessage(webSocket: WebSocket, text: String) { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { val message = runCatching { json.decodeFromString(text) }.getOrElse { Log.d(TAG, "Failed to parse incoming message: ", it) @@ -349,7 +364,10 @@ class SevenTVEventApiClient( private inline fun T.encodeOrNull(): String? = runCatching { json.encodeToString(this) }.getOrNull() - data class Status(val connected: Boolean, val subscriptionCount: Int) + data class Status( + val connected: Boolean, + val subscriptionCount: Int, + ) fun status(): Status = Status(connected = connected, subscriptionCount = subscriptions.size) 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 497286ac3..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,12 +4,29 @@ 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, val added: List, val removed: List, val updated: List) : - SevenTVEventMessage { - data class UpdatedEmote(val id: String, val name: String, val oldName: String) + data class EmoteSetUpdated( + val emoteSetId: String, + val actorName: DisplayName, + val added: List, + 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 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 65a7ca7d9..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,15 +25,24 @@ 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) : DispatchData +data class EmoteSetDispatchData( + override val body: EmoteSetChangeMapData, +) : DispatchData @Serializable -data class EmoteSetChangeMapData(override val id: String, override val actor: Actor, val pushed: List?, val pulled: List?, val updated: List?) : - ChangeMapData +data class EmoteSetChangeMapData( + override val id: String, + override val actor: Actor, + val pushed: List?, + val pulled: List?, + val updated: List?, +) : ChangeMapData @Serializable @JsonClassDiscriminator("key") @@ -39,25 +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) : DispatchData +data class UserDispatchData( + override val body: UserChangeMapData, +) : DispatchData @Serializable -data class UserChangeMapData(override val id: String, override val actor: Actor, val updated: List?) : ChangeMapData +data class UserChangeMapData( + override val id: String, + override val actor: Actor, + 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 @@ -65,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 4b3c84496..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,12 @@ 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(@SerialName("heartbeat_interval") val heartBeatInterval: Int, @SerialName("session_id") val sessionId: String) : Data +data class HelloData( + @SerialName("heartbeat_interval") val heartBeatInterval: Int, + @SerialName("session_id") val sessionId: String, +) : Data 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 77d355702..99d5b2562 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 @@ -6,20 +6,30 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SubscribeRequest(override val op: Int = 35, override val d: SubscriptionData) : DataRequest { +data class SubscribeRequest( + override val op: Int = 35, + override val d: SubscriptionData, +) : DataRequest { companion object { - fun userUpdates(userId: String) = SubscribeRequest( - d = SubscriptionData(type = UserUpdates.type, condition = SubscriptionCondition(objectId = userId)), - ) + fun userUpdates(userId: String) = + SubscribeRequest( + d = SubscriptionData(type = UserUpdates.type, condition = SubscriptionCondition(objectId = userId)), + ) - fun emoteSetUpdates(emoteSetId: String) = SubscribeRequest( - d = SubscriptionData(type = EmoteSetUpdates.type, condition = SubscriptionCondition(objectId = emoteSetId)), - ) + fun emoteSetUpdates(emoteSetId: String) = + SubscribeRequest( + d = SubscriptionData(type = EmoteSetUpdates.type, condition = SubscriptionCondition(objectId = emoteSetId)), + ) } } @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 828ed75cf..7bf3b8833 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,19 @@ 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)), - ) + fun userUpdates(userId: String) = + UnsubscribeRequest( + 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)), - ) + fun emoteSetUpdates(emoteSetId: String) = + UnsubscribeRequest( + 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 691b19d09..c489d5f57 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,10 +5,13 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.parameter -class SupibotApi(private val ktorClient: HttpClient) { - suspend fun getChannels(platformName: String = "twitch") = ktorClient.get("bot/channel/list") { - parameter("platformName", platformName) - } +class SupibotApi( + private val ktorClient: HttpClient, +) { + suspend fun getChannels(platformName: String = "twitch") = + ktorClient.get("bot/channel/list") { + parameter("platformName", platformName) + } suspend fun getCommands() = ktorClient.get("bot/command/list/") 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 8324a7bf7..6baa5c9c8 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,25 +10,31 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class SupibotApiClient(private val supibotApi: SupibotApi, private val json: Json) { - suspend fun getSupibotCommands(): Result = runCatching { - supibotApi - .getCommands() - .throwApiErrorOnFailure(json) - .body() - } +class SupibotApiClient( + private val supibotApi: SupibotApi, + private val json: Json, +) { + suspend fun getSupibotCommands(): Result = + runCatching { + supibotApi + .getCommands() + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getSupibotChannels(): Result = runCatching { - supibotApi - .getChannels() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getSupibotChannels(): Result = + runCatching { + supibotApi + .getChannels() + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getSupibotUserAliases(user: UserName): Result = runCatching { - supibotApi - .getUserAliases(user) - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getSupibotUserAliases(user: UserName): Result = + runCatching { + 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 21295b35f..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" } 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 d9ea08412..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) +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 e1c32a22e..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) +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 e76e084d1..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,4 +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 0deb78577..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) +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 9cd5106db..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) +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 85cf1c8f8..a2cbd8876 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 @@ -26,93 +26,99 @@ import java.net.URLConnection import java.time.Instant @Single -class UploadClient(@Named(type = UploadOkHttpClient::class) private val httpClient: OkHttpClient, private val toolsSettingsDataStore: ToolsSettingsDataStore) { - suspend fun uploadMedia(file: File): Result = withContext(Dispatchers.IO) { - val uploader = toolsSettingsDataStore.settings.first().uploaderConfig - val mimetype = URLConnection.guessContentTypeFromName(file.name) +class UploadClient( + @Named(type = UploadOkHttpClient::class) private val httpClient: OkHttpClient, + private val toolsSettingsDataStore: ToolsSettingsDataStore, +) { + suspend fun uploadMedia(file: File): Result = + withContext(Dispatchers.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) - } - }.post(requestBody) - .build() + 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) - } + val response = + runCatching { + httpClient.newCall(request).execute() + }.getOrElse { + return@withContext Result.failure(it) + } - when { - response.isSuccessful -> { - val imageLinkPattern = uploader.imageLinkPattern - val deletionLinkPattern = uploader.deletionLinkPattern + when { + response.isSuccessful -> { + val imageLinkPattern = uploader.imageLinkPattern + val deletionLinkPattern = uploader.deletionLinkPattern - if (imageLinkPattern == null || imageLinkPattern.isBlank()) { - return@withContext runCatching { - val body = response.body.string() - UploadDto( - imageLink = body, - deleteLink = null, - timestamp = Instant.now(), - ) + if (imageLinkPattern == null || imageLinkPattern.isBlank()) { + return@withContext runCatching { + val body = response.body.string() + UploadDto( + imageLink = body, + deleteLink = null, + timestamp = Instant.now(), + ) + } } - } - response - .asJsonObject() - .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(), - ) - } - } + response + .asJsonObject() + .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(), + ) + } + } - else -> { - Log.e(TAG, "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)) + else -> { + Log.e(TAG, "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)) + } } } - } @Suppress("RegExpRedundantEscape") - private suspend fun JSONObject.extractLink(linkPattern: String): String = withContext(Dispatchers.Default) { - var imageLink: String = linkPattern + 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) + val regex = "\\{(.+?)\\}".toRegex() + regex.findAll(linkPattern).forEach { + val jsonValue = getValue(it.groupValues[1]) + if (jsonValue != null) { + imageLink = imageLink.replace(it.groupValues[0], jsonValue) + } } + imageLink } - imageLink - } - private fun Response.asJsonObject(): Result = runCatching { - val bodyString = body.string() - JSONObject(bodyString) - }.onFailure { - Log.d(TAG, "Error creating JsonObject from response: ", it) - } + private fun Response.asJsonObject(): Result = + runCatching { + val bodyString = body.string() + JSONObject(bodyString) + }.onFailure { + Log.d(TAG, "Error creating JsonObject from response: ", it) + } private fun JSONObject.getValue(pattern: String): String? { return runCatching { 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 0b88f9644..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 @@ -2,4 +2,8 @@ package com.flxrs.dankchat.data.api.upload.dto import java.time.Instant -data class UploadDto(val imageLink: String, val deleteLink: String?, val timestamp: Instant) +data class UploadDto( + val imageLink: String, + val deleteLink: String?, + 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 index 932849585..c7883ceee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt @@ -23,7 +23,10 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class AuthDataStore(context: Context, dispatchersProvider: DispatchersProvider) { +class AuthDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { private val legacyPrefs: SharedPreferences = context.getSharedPreferences( "com.flxrs.dankchat_preferences", @@ -32,9 +35,10 @@ class AuthDataStore(context: Context, dispatchersProvider: DispatchersProvider) 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 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) @@ -104,7 +108,12 @@ class AuthDataStore(context: Context, dispatchersProvider: DispatchersProvider) persistScope.launch { update(transform) } } - suspend fun login(oAuthKey: String, userName: String, userId: String, clientId: String) { + suspend fun login( + oAuthKey: String, + userName: String, + userId: String, + clientId: String, + ) { update { it.copy( oAuthKey = "oauth:$oAuthKey", 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 index 5184a4e47..9bef57757 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -26,9 +26,13 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single sealed interface AuthEvent { - data class LoggedIn(val userName: UserName) : AuthEvent + data class LoggedIn( + val userName: UserName, + ) : AuthEvent - data class ScopesOutdated(val userName: UserName) : AuthEvent + data class ScopesOutdated( + val userName: UserName, + ) : AuthEvent data object TokenInvalid : AuthEvent 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 index b9260d3ea..d078b3fdd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt @@ -12,7 +12,9 @@ sealed interface StartupValidation { data object Validated : StartupValidation - data class ScopesOutdated(val userName: UserName) : StartupValidation + data class ScopesOutdated( + val userName: UserName, + ) : StartupValidation data object TokenInvalid : StartupValidation } 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 index 98124efe4..f04d3c9f8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt @@ -2,6 +2,12 @@ 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) +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/debug/ApiDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt index 89ae96283..47b5ff3be 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.flow.flow import org.koin.core.annotation.Single @Single -class ApiDebugSection(private val helixApiStats: HelixApiStats) : DebugSection { +class ApiDebugSection( + private val helixApiStats: HelixApiStats, +) : DebugSection { override val order = 10 override val baseTitle = "API" 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 index 96554d8ef..66e73e1c0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt @@ -31,12 +31,12 @@ class AppDebugSection : DebugSection { DebugSectionSnapshot( title = baseTitle, entries = - listOf( - DebugEntry("Total app memory", formatBytes(totalAppMemory)), - DebugEntry("JVM heap", "${formatBytes(heapUsed)} / ${formatBytes(heapMax)}"), - DebugEntry("Native heap", "${formatBytes(nativeAllocated)} / ${formatBytes(nativeTotal)}"), - DebugEntry("Threads", "${Thread.activeCount()}"), - ), + listOf( + DebugEntry("Total app memory", formatBytes(totalAppMemory)), + DebugEntry("JVM heap", "${formatBytes(heapUsed)} / ${formatBytes(heapMax)}"), + DebugEntry("Native heap", "${formatBytes(nativeAllocated)} / ${formatBytes(nativeTotal)}"), + DebugEntry("Threads", "${Thread.activeCount()}"), + ), ) } } 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 index e4d239dfb..3b437b7e8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt @@ -7,25 +7,28 @@ import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single -class AuthDebugSection(private val authDataStore: AuthDataStore) : DebugSection { +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), - ), - ) - } + 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 index c1c956ada..5ead5f81c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt @@ -10,14 +10,15 @@ 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), + 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 index 71d4902df..177d84d07 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt @@ -10,37 +10,41 @@ 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 { +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")))) - } + 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 -> { + 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" })) + else -> { + val display = roomState.toDebugText() + add(DebugEntry("Room state", display.ifEmpty { "None" })) + } } } - } - DebugSectionSnapshot(title = "$baseTitle (${channel.value})", entries = entries) + 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 index 49e27b9b2..d71fddf57 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt @@ -61,14 +61,14 @@ class ConnectionDebugSection( 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), - ), + 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 index 37f687617..2519c457c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt @@ -9,6 +9,13 @@ interface DebugSection { fun entries(): Flow } -data class DebugSectionSnapshot(val title: String, val entries: List) +data class DebugSectionSnapshot( + val title: String, + val entries: List, +) -data class DebugEntry(val label: String, val value: String, val copyValue: String? = null) +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 index 66e2122e6..a49876cf1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt @@ -6,7 +6,9 @@ import kotlinx.coroutines.flow.flowOf import org.koin.core.annotation.Single @Single -class DebugSectionRegistry(sections: List) { +class DebugSectionRegistry( + sections: List, +) { private val sorted = sections.sortedBy { it.order } fun allSections(): Flow> { 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 index f6a27dd40..e664d6846 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt @@ -11,48 +11,53 @@ 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 { +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}")), - ) - } + 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}"), - ), - ) + 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 index e4c857880..67aa85de7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt @@ -7,22 +7,26 @@ import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Single @Single -class ErrorsDebugSection(private val dataRepository: DataRepository, private val chatMessageRepository: ChatMessageRepository) : DebugSection { +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")) + 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")) + } } - chatFailures.forEach { failure -> - add(DebugEntry(failure.step::class.simpleName ?: "Unknown", failure.failure.message ?: "Unknown error")) - } - } - DebugSectionSnapshot(title = baseTitle, entries = entries) - } + 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 index 310f07b57..acd1db724 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt @@ -7,27 +7,31 @@ import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Single @Single -class RulesDebugSection(private val highlightsRepository: HighlightsRepository, private val ignoresRepository: IgnoresRepository) : DebugSection { +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}"), - ), - ) - } + 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 index fd309774d..0147994c5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt @@ -44,15 +44,15 @@ class SessionDebugSection( 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}"), - ), + 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 index 7e23235e8..4e2f6ffce 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt @@ -8,34 +8,38 @@ import kotlinx.coroutines.flow.combine import org.koin.core.annotation.Single @Single -class StreamDebugSection(private val streamDataRepository: StreamDataRepository, private val chatChannelProvider: ChatChannelProvider) : DebugSection { +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")), - ) - } + 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}"), - ), - ) + 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 index d5c6842bd..4d7beebc8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt @@ -6,18 +6,21 @@ import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single -class UserStateDebugSection(private val userStateRepository: UserStateRepository) : DebugSection { +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" }), - ), - ) - } + 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 07390d99a..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 @@ -2,7 +2,13 @@ package com.flxrs.dankchat.data.irc import java.text.ParseException -data class IrcMessage(val raw: String, val prefix: String, val command: String, val params: List = listOf(), val tags: Map = mapOf()) { +data class IrcMessage( + val raw: String, + val prefix: String, + val command: String, + val params: List = listOf(), + val tags: Map = mapOf(), +) { fun isLoginFailed(): Boolean = command == "NOTICE" && params.getOrNull(0) == "*" && params.getOrNull(1) == "Login authentication failed" companion object { 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 3018f594d..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 @@ -6,7 +6,13 @@ import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.data.twitch.message.WhisperMessage import com.flxrs.dankchat.data.twitch.message.shouldNotify -data class NotificationData(val channel: UserName, val name: UserName, val message: String, val isWhisper: Boolean = false, val isNotify: Boolean = false) +data class NotificationData( + val channel: UserName, + val name: UserName, + val message: String, + val isWhisper: Boolean = false, + val isNotify: Boolean = false, +) fun Message.toNotificationData(): NotificationData? { if (!highlights.shouldNotify()) { 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 e9eb7c05c..54d24c06f 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 @@ -73,7 +73,9 @@ class NotificationService : override val coroutineContext: CoroutineContext get() = Dispatchers.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 @@ -116,7 +118,11 @@ class NotificationService : .launchIn(this) } - 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() @@ -125,7 +131,10 @@ class NotificationService : return START_NOT_STICKY } - override fun onTimeout(startId: Int, fgsType: Int) { + override fun onTimeout( + startId: Int, + fgsType: Int, + ) { Log.w(TAG, "Stopping foreground service due to 6h timeout restriction..") stopSelf() } @@ -145,10 +154,11 @@ class NotificationService : shouldNotifyOnMention = true } - private suspend fun setTTSEnabled(enabled: Boolean) = when { - enabled -> initTTS() - else -> shutdownTTS() - } + private suspend fun setTTSEnabled(enabled: Boolean) = + when { + enabled -> initTTS() + else -> shutdownTTS() + } private suspend fun initTTS() { val forceEnglish = toolsSettingsDataStore.settings.first().ttsForceEnglish @@ -310,30 +320,33 @@ class NotificationService : 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) + private fun String.filterEmotes(emotes: List): String = + when { + toolSettings.ttsIgnoreEmotes -> { + emotes.fold(this) { acc, emote -> + acc.replace(emote.code, newValue = "", ignoreCase = true) + } } - } - else -> { - this + 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 = "") + 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 - } + else -> this + } - private fun String.filterUrls(): String = when { - toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") - else -> this - } + private fun String.filterUrls(): String = + when { + toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") + else -> this + } private fun NotificationData.createMentionNotification() { val pendingStartActivityIntent = 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 bf1361bd8..5f2f9e15c 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 @@ -85,38 +85,40 @@ class HighlightsRepository( .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 - } + 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...") - messageHighlightDao.addHighlights(DEFAULT_MESSAGE_HIGHLIGHTS) - val totalMessageHighlights = DEFAULT_MESSAGE_HIGHLIGHTS.size - Log.d(TAG, "Message highlights migration completed, added $totalMessageHighlights entries.") - } - if (badgeHighlightDao.getBadgeHighlights().isEmpty()) { - Log.d(TAG, "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.") - } - }.getOrElse { - Log.e(TAG, "Failed to run highlights migration", it) + fun runMigrationsIfNeeded() = + coroutineScope.launch { runCatching { - messageHighlightDao.deleteAllHighlights() - userHighlightDao.deleteAllHighlights() - badgeHighlightDao.deleteAllHighlights() - return@launch + if (messageHighlightDao.getMessageHighlights().isEmpty()) { + Log.d(TAG, "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.") + } + if (badgeHighlightDao.getBadgeHighlights().isEmpty()) { + Log.d(TAG, "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.") + } + }.getOrElse { + Log.e(TAG, "Failed to run highlights migration", it) + runCatching { + messageHighlightDao.deleteAllHighlights() + userHighlightDao.deleteAllHighlights() + badgeHighlightDao.deleteAllHighlights() + return@launch + } } } - } suspend fun addMessageHighlight(): MessageHighlightEntity { val entity = @@ -337,10 +339,11 @@ class HighlightsRepository( return copy(highlights = highlights) } - private suspend fun WhisperMessage.calculateHighlightState(): WhisperMessage = when { - notificationsSettingsDataStore.settings.first().showWhisperNotifications -> copy(highlights = setOf(Highlight(HighlightType.Notification))) - else -> this - } + private suspend fun WhisperMessage.calculateHighlightState(): WhisperMessage = + when { + notificationsSettingsDataStore.settings.first().showWhisperNotifications -> copy(highlights = setOf(Highlight(HighlightType.Notification))) + else -> this + } private fun List.ofType(type: MessageHighlightEntityType): MessageHighlightEntity? = find { it.type == type } @@ -392,13 +395,14 @@ class HighlightsRepository( return false } - private fun List.addDefaultsIfNecessary(): List = (this + DEFAULT_MESSAGE_HIGHLIGHTS) - .distinctBy { - when (it.type) { - MessageHighlightEntityType.Custom -> it.id - else -> it.type - } - }.sortedBy { it.type.ordinal } + private fun List.addDefaultsIfNecessary(): List = + (this + DEFAULT_MESSAGE_HIGHLIGHTS) + .distinctBy { + when (it.type) { + MessageHighlightEntityType.Custom -> it.id + else -> it.type + } + }.sortedBy { it.type.ordinal } companion object { private val TAG = HighlightsRepository::class.java.simpleName 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 44d49d81a..e88ec8521 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 @@ -47,7 +47,10 @@ class IgnoresRepository( ) { 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()) @@ -64,70 +67,76 @@ class IgnoresRepository( .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 { - runCatching { - if (messageIgnoreDao.getMessageIgnores().isNotEmpty()) { - return@launch - } - - Log.d(TAG, "Running ignores migration...") - messageIgnoreDao.addIgnores(DEFAULT_IGNORES) + 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 + } - val totalIgnores = DEFAULT_IGNORES.size - Log.d(TAG, "Ignores migration completed, added $totalIgnores entries.") - }.getOrElse { - Log.e(TAG, "Failed to run ignores migration", it) + fun runMigrationsIfNeeded() = + coroutineScope.launch { runCatching { - messageIgnoreDao.deleteAllIgnores() - userIgnoreDao.deleteAllIgnores() - return@launch + if (messageIgnoreDao.getMessageIgnores().isNotEmpty()) { + return@launch + } + + Log.d(TAG, "Running ignores migration...") + messageIgnoreDao.addIgnores(DEFAULT_IGNORES) + + val totalIgnores = DEFAULT_IGNORES.size + Log.d(TAG, "Ignores migration completed, added $totalIgnores entries.") + }.getOrElse { + Log.e(TAG, "Failed to run ignores migration", it) + runCatching { + messageIgnoreDao.deleteAllIgnores() + userIgnoreDao.deleteAllIgnores() + return@launch + } } } - } fun isUserBlocked(userId: UserId?): Boolean = _twitchBlocks.value.any { it.id == userId } - suspend fun loadUserBlocks() = withContext(Dispatchers.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) + suspend fun loadUserBlocks() = + withContext(Dispatchers.Default) { + if (!preferences.isLoggedIn) { 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) + + 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 + } + if (blocks.isEmpty()) { + _twitchBlocks.update { emptySet() } return@withContext } - val twitchBlocks = - users.mapTo(mutableSetOf()) { user -> - TwitchBlock( - id = user.id, - name = user.name, - ) - } + 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, + ) + } - _twitchBlocks.update { twitchBlocks } - } + _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 { @@ -140,7 +149,10 @@ class IgnoresRepository( } } - suspend fun removeUserBlock(targetUserId: UserId, targetUsername: UserName) { + suspend fun removeUserBlock( + targetUserId: UserId, + targetUsername: UserName, + ) { val result = helixApiClient.unblockUser(targetUserId) if (result.isSuccess) { _twitchBlocks.update { @@ -312,9 +324,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 @@ -331,19 +350,23 @@ class IgnoresRepository( } } - 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 - pos.first + offset..pos.last + offset // add sum of changes to the emote position - } - emoteWithPos.copy(positions = adjusted) - } + 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 + pos.first + offset..pos.last + offset // add sum of changes to the emote position + } + emoteWithPos.copy(positions = adjusted) + } private operator fun IntRange.contains(other: IntRange): Boolean = other.first >= first && other.last <= last 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 322163207..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 @@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.Flow import org.koin.core.annotation.Single @Single -class RecentUploadsRepository(private val recentUploadsDao: RecentUploadsDao) { +class RecentUploadsRepository( + private val recentUploadsDao: RecentUploadsDao, +) { fun getRecentUploads(): Flow> = recentUploadsDao.getRecentUploads() suspend fun addUpload(upload: UploadDto) { 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 f080ae666..8a3ff5171 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 @@ -20,14 +20,17 @@ import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap @Single -class RepliesRepository(private val authDataStore: AuthDataStore) { +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) } - listOf(root) + replies - } ?: flowOf(emptyList()) + 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) } + listOf(root) + replies + } ?: flowOf(emptyList()) fun hasMessageThread(rootMessageId: String) = threads.containsKey(rootMessageId) @@ -46,7 +49,10 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { .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 } @@ -159,7 +165,10 @@ class RepliesRepository(private val authDataStore: AuthDataStore) { return this } - private fun createPlaceholderRootMessage(reply: PrivMessage, rootId: String): PrivMessage? { + 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() 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 eaed987de..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 @@ -18,7 +18,10 @@ import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single @Single -class UserDisplayRepository(private val userDisplayDao: UserDisplayDao, dispatchersProvider: DispatchersProvider) { +class UserDisplayRepository( + private val userDisplayDao: UserDisplayDao, + dispatchersProvider: DispatchersProvider, +) { private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) val userDisplays = userDisplayDao diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/Channel.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/Channel.kt index 11f287434..c6b4134d4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/Channel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/Channel.kt @@ -4,4 +4,9 @@ import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -data class Channel(val id: UserId, val name: UserName, val displayName: DisplayName, val avatarUrl: String?) +data class Channel( + val id: UserId, + val name: UserName, + val displayName: DisplayName, + val avatarUrl: String?, +) 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 4925b7557..f7c7c9a5b 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 @@ -21,7 +21,11 @@ import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap @Single -class ChannelRepository(private val usersRepository: UsersRepository, private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore) { +class ChannelRepository( + private val usersRepository: UsersRepository, + private val helixApiClient: HelixApiClient, + private val authDataStore: AuthDataStore, +) { private val channelCache = ConcurrentHashMap() private val roomStates = ConcurrentHashMap() private val roomStateFlows = ConcurrentHashMap>() @@ -80,9 +84,10 @@ class ChannelRepository(private val usersRepository: UsersRepository, private va 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) - } + fun getRoomStateFlow(channel: UserName): SharedFlow = + roomStateFlows.getOrPut(channel) { + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + } fun getRoomState(channel: UserName): RoomState? = roomStateFlows[channel]?.firstValueOrNull @@ -104,43 +109,45 @@ class ChannelRepository(private val usersRepository: UsersRepository, private va flow.tryEmit(state) } - suspend fun getChannelsByIds(ids: Collection): List = withContext(Dispatchers.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) } + suspend fun getChannelsByIds(ids: Collection): List = + withContext(Dispatchers.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 + } - channels.forEach { channelCache[it.name] = it } - return@withContext cached + channels - } + val channels = + helixApiClient + .getUsersByIds(remaining) + .getOrNull() + .orEmpty() + .map { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } - suspend fun getChannels(names: Collection): List = withContext(Dispatchers.IO) { - val cached = names.mapNotNull { channelCache[it] } - val cachedNames = cached.mapTo(mutableSetOf(), Channel::name) - val remaining = names - cachedNames - if (remaining.isEmpty() || !authDataStore.isLoggedIn) { - return@withContext cached + channels.forEach { channelCache[it.name] = it } + return@withContext cached + channels } - val channels = - helixApiClient - .getUsersByNames(remaining) - .getOrNull() - .orEmpty() - .map { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } + suspend fun getChannels(names: Collection): List = + withContext(Dispatchers.IO) { + val cached = names.mapNotNull { channelCache[it] } + val cachedNames = cached.mapTo(mutableSetOf(), Channel::name) + val remaining = names - cachedNames + if (remaining.isEmpty() || !authDataStore.isLoggedIn) { + return@withContext cached + } - channels.forEach { channelCache[it.name] = it } - return@withContext cached + channels - } + 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 + } fun cacheChannels(channels: List) { channels.forEach { channelCache[it.name] = it } 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 index 63dd3ad59..9554e1ec7 100644 --- 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 @@ -10,7 +10,9 @@ import kotlinx.coroutines.flow.asStateFlow import org.koin.core.annotation.Single @Single -class ChatChannelProvider(preferenceStore: DankChatPreferenceStore) { +class ChatChannelProvider( + preferenceStore: DankChatPreferenceStore, +) { private val _activeChannel = MutableStateFlow(null) private val _channels = MutableStateFlow(preferenceStore.channels.takeIf { it.isNotEmpty() }?.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 index fc138f48d..a58b85857 100644 --- 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 @@ -60,12 +60,13 @@ class ChatConnector( } } - fun closeAndReconnect(channels: List) = scope.launch { - readConnection.close() - writeConnection.close() - eventSubManager.close() - connectAndJoin(channels) - } + fun closeAndReconnect(channels: List) = + scope.launch { + readConnection.close() + writeConnection.close() + eventSubManager.close() + connectAndJoin(channels) + } fun reconnect(reconnectPubsub: Boolean = true) { readConnection.reconnect() 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 index db749d78d..cc279e977 100644 --- 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 @@ -85,7 +85,10 @@ class ChatEventProcessor( fun getLastMessageForDisplay(channel: UserName?): String? = channel?.let { lastMessage[it]?.withoutInvisibleChar } - fun setLastMessage(channel: UserName, message: String) { + fun setLastMessage( + channel: UserName, + message: String, + ) { lastMessage[channel] = message } @@ -93,7 +96,10 @@ class ChatEventProcessor( lastMessage.remove(channel) } - suspend fun loadRecentMessages(channel: UserName, isReconnect: Boolean = false) { + suspend fun loadRecentMessages( + channel: UserName, + isReconnect: Boolean = false, + ) { val result = recentMessagesHandler.load(channel, isReconnect) chatNotificationRepository.addMentionsDeduped(result.mentionItems) usersRepository.updateUsers(channel, result.userSuggestions) @@ -317,11 +323,11 @@ class ChatEventProcessor( badges = listOf(automodBadge), isUserSide = true, status = - when (data.status) { - AutomodMessageStatus.Approved -> AutomodMessage.Status.Approved - AutomodMessageStatus.Denied -> AutomodMessage.Status.Denied - AutomodMessageStatus.Expired -> AutomodMessage.Status.Expired - }, + 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))) } @@ -580,34 +586,41 @@ class ChatEventProcessor( } } - private fun ConnectionState.toSystemMessageType(): SystemMessageType = when (this) { - ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected - - ConnectionState.CONNECTED, - ConnectionState.CONNECTED_NOT_LOGGED_IN, - -> SystemMessageType.Connected - } + private fun ConnectionState.toSystemMessageType(): SystemMessageType = + when (this) { + ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected - 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)) + ConnectionState.CONNECTED, + ConnectionState.CONNECTED_NOT_LOGGED_IN, + -> SystemMessageType.Connected } - 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)) - } + 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) + else -> { + TextResource.Plain(reason) + } } - } companion object { private val TAG = ChatEventProcessor::class.java.simpleName 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 d5bbccc4a..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 @@ -9,7 +9,9 @@ sealed interface ChatLoadingStep { @get:StringRes val displayNameRes: Int - data class RecentMessages(val channel: UserName) : ChatLoadingStep { + data class RecentMessages( + val channel: UserName, + ) : ChatLoadingStep { override val displayNameRes = R.string.data_loading_step_recent_messages } } 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 index 56f81bccf..f21e05d14 100644 --- 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 @@ -79,10 +79,16 @@ class ChatMessageRepository( fun getMessagesFlow(channel: UserName): MutableStateFlow>? = messages[channel] - 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) { + 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) @@ -113,7 +119,11 @@ class ChatMessageRepository( messages[channel]?.value = emptyList() } - fun updateAutomodMessageStatus(channel: UserName, heldMessageId: String, status: AutomodMessage.Status) { + fun updateAutomodMessageStatus( + channel: UserName, + heldMessageId: String, + status: AutomodMessage.Status, + ) { messages[channel]?.update { current -> current.map { item -> val msg = item.message @@ -130,30 +140,37 @@ class ChatMessageRepository( } } - suspend fun reparseAllEmotesAndBadges() = withContext(Dispatchers.Default) { - messages.values - .map { flow -> - async { - flow.update { items -> - items.map { - it.copy( - tag = it.tag + 1, - message = messageProcessor.reparseEmotesAndBadges(it.message), - ) + suspend fun reparseAllEmotesAndBadges() = + withContext(Dispatchers.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() - } + }.awaitAll() + chatNotificationRepository.reparseAll() + } - fun addSystemMessage(channel: UserName, type: SystemMessageType) { + fun addSystemMessage( + channel: UserName, + type: SystemMessageType, + ) { messages[channel]?.update { it.addSystemMessage(type, scrollBackLength, messageProcessor::onMessageRemoved) } } - fun addSystemMessageToChannels(type: SystemMessageType, channels: Set = messages.keys): Set { + fun addSystemMessageToChannels( + type: SystemMessageType, + channels: Set = messages.keys, + ): Set { val reconnectedChannels = mutableSetOf() channels.forEach { channel -> val flow = messages[channel] ?: return@forEach 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 index f020dec28..edaf0630d 100644 --- 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 @@ -24,7 +24,12 @@ class ChatMessageSender( private val chatEventProcessor: ChatEventProcessor, private val developerSettingsDataStore: DeveloperSettingsDataStore, ) { - suspend fun send(channel: UserName, message: String, replyId: String? = null, forceIrc: Boolean = false) { + suspend fun send( + channel: UserName, + message: String, + replyId: String? = null, + forceIrc: Boolean = false, + ) { if (message.isBlank()) { return } @@ -36,7 +41,11 @@ class ChatMessageSender( } } - private suspend fun sendViaIrc(channel: UserName, message: String, replyId: String?) { + 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() @@ -52,7 +61,11 @@ class ChatMessageSender( chatMessageRepository.incrementSentMessageCount(ChatSendProtocol.IRC) } - private suspend fun sendViaHelix(channel: UserName, message: String, replyId: String?) { + private suspend fun sendViaHelix( + channel: UserName, + message: String, + replyId: String?, + ) { val trimmedMessage = message.trimEnd() val senderId = authDataStore.userIdString ?: run { @@ -98,7 +111,10 @@ class ChatMessageSender( ) } - private fun postError(channel: UserName, type: SystemMessageType) { + private fun postError( + channel: UserName, + type: SystemMessageType, + ) { chatMessageRepository.addSystemMessage(channel, type) chatMessageRepository.incrementSendFailureCount() } @@ -117,22 +133,23 @@ class ChatMessageSender( } } - 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) + 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) + else -> { + SystemMessageType.SendFailed(message) + } } - } companion object { private val TAG = ChatMessageSender::class.java.simpleName 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 index 445242081..d50e45148 100644 --- 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 @@ -112,17 +112,22 @@ class ChatNotificationRepository( } } - fun incrementMentionCount(channel: UserName, count: Int) { + fun incrementMentionCount( + channel: UserName, + count: Int, + ) { _channelMentionCount.increment(channel, count) } - fun clearMentionCount(channel: UserName) = with(_channelMentionCount) { - tryEmit(firstValue.apply { set(channel, 0) }) - } + 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 clearMentionCounts() = + with(_channelMentionCount) { + tryEmit(firstValue.apply { keys.forEach { if (it != WhisperMessage.WHISPER_CHANNEL) set(it, 0) } }) + } fun clearUnreadMessage(channel: UserName) { _unreadMessagesMap.assign(channel, false) 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 8c6c217e5..93fa9dfa5 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 @@ -75,7 +75,11 @@ class ChatRepository( channelRepository.initRoomState(channel) } - suspend fun sendMessage(input: String, replyId: String? = null, forceIrc: Boolean = false) { + suspend fun sendMessage( + input: String, + replyId: String? = null, + forceIrc: Boolean = false, + ) { val channel = chatChannelProvider.activeChannel.value ?: return chatMessageSender.send(channel, input, replyId, forceIrc) } @@ -114,7 +118,10 @@ class ChatRepository( fun getLastMessage(): String? = chatEventProcessor.getLastMessageForDisplay(chatChannelProvider.activeChannel.value) - fun appendLastMessage(channel: UserName, message: String) = chatEventProcessor.setLastMessage(channel, message) + fun appendLastMessage( + channel: UserName, + message: String, + ) = chatEventProcessor.setLastMessage(channel, message) suspend fun loadRecentMessagesIfEnabled(channel: UserName) { when { @@ -130,7 +137,10 @@ class ChatRepository( } } - fun makeAndPostCustomSystemMessage(msg: String, channel: UserName) { + fun makeAndPostCustomSystemMessage( + msg: String, + channel: UserName, + ) { chatMessageRepository.addSystemMessage(channel, SystemMessageType.Custom(msg)) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt index 9ce6d4429..b9d5643ac 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt @@ -28,31 +28,41 @@ class MessageProcessor( private val channelRepository: ChannelRepository, ) { /** Full pipeline: parse IRC → ignore → thread → display → emotes → highlights → thread update. Returns null if ignored or parse fails. */ - suspend fun processIrcMessage(ircMessage: IrcMessage, findMessageById: (UserName, String) -> Message? = { _, _ -> null }): Message? = Message - .parse(ircMessage, channelRepository::tryGetUserNameById) - ?.let { process(it, findMessageById) } + suspend fun processIrcMessage( + ircMessage: IrcMessage, + findMessageById: (UserName, String) -> 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() + 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() + 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() + 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() 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 index fa3717e41..58d4bd7f0 100644 --- 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 @@ -37,107 +37,117 @@ class RecentMessagesHandler( ) { private val loadedChannels = mutableSetOf() - data class Result(val mentionItems: List, val userSuggestions: List>) + data class Result( + val mentionItems: List, + val userSuggestions: List>, + ) @Suppress("LoopWithTooManyJumpStatements") - suspend fun load(channel: UserName, isReconnect: Boolean = false): Result = withContext(Dispatchers.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) - } + suspend fun load( + channel: UserName, + isReconnect: Boolean = false, + ): Result = + withContext(Dispatchers.IO) { + if (!isReconnect && channel in loadedChannels) { 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 + 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()) } - when (parsedIrc.command) { - "CLEARCHAT" -> { - val parsed = - runCatching { - ModerationMessage.parseClearChat(parsedIrc) - }.getOrNull() ?: continue - - items.replaceOrAddHistoryModerationMessage(parsed) + 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 } - "CLEARMSG" -> { - val parsed = - runCatching { - ModerationMessage.parseClearMessage(parsedIrc) - }.getOrNull() ?: continue + when (parsedIrc.command) { + "CLEARCHAT" -> { + val parsed = + runCatching { + ModerationMessage.parseClearChat(parsedIrc) + }.getOrNull() ?: continue - items += ChatItem(parsed, importance = ChatImportance.SYSTEM) - } + items.replaceOrAddHistoryModerationMessage(parsed) + } - 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 - if (message.color != Message.DEFAULT_COLOR) { - usersRepository.cacheUserColor(message.name, message.color) - } + "CLEARMSG" -> { + val parsed = + runCatching { + ModerationMessage.parseClearMessage(parsedIrc) + }.getOrNull() ?: continue + + items += ChatItem(parsed, importance = ChatImportance.SYSTEM) } - val importance = - when { - isDeleted -> ChatImportance.DELETED - isReconnect -> ChatImportance.SYSTEM - else -> ChatImportance.REGULAR + 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 + if (message.color != Message.DEFAULT_COLOR) { + usersRepository.cacheUserColor(message.name, message.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) } - if (message is UserNoticeMessage && message.childMessage != null) { - items += ChatItem(message.childMessage, importance = importance) + items += ChatItem(message, importance = importance) } - items += ChatItem(message, importance = importance) } } - } - }.let { Log.i(TAG, "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() - } + }.let { Log.i(TAG, "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 + else -> { + current + } } - } - withIncompleteWarning.addAndLimit(items, chatMessageRepository.scrollBackLength, messageProcessor::onMessageRemoved, checkForDuplications = true) - } + withIncompleteWarning.addAndLimit(items, chatMessageRepository.scrollBackLength, messageProcessor::onMessageRemoved, checkForDuplications = true) + } - val mentionItems = items.filter { it.message.highlights.hasMention() }.toMentionTabItems() - Result(mentionItems, userSuggestions) - } + val mentionItems = items.filter { it.message.highlights.hasMention() }.toMentionTabItems() + Result(mentionItems, userSuggestions) + } - private fun handleFailure(throwable: Throwable, channel: UserName) { + private fun handleFailure( + throwable: Throwable, + channel: UserName, + ) { val type = when (throwable) { !is RecentMessagesApiException -> { 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 00f22fdbb..068872e6f 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,11 @@ 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 - } + fun getSendDelay(channel: UserName): Duration = + when { + hasHighRateLimit(channel) -> LOW_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 35d131316..f438f0280 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,17 +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() - - suspend fun tryGetUserStateOrFallback(minChannelsSize: Int, initialTimeout: Duration = IRC_TIMEOUT_DELAY, fallbackTimeout: Duration = IRC_TIMEOUT_SHORT_DELAY): UserState? = + 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() + + suspend fun tryGetUserStateOrFallback( + minChannelsSize: Int, + initialTimeout: Duration = IRC_TIMEOUT_DELAY, + fallbackTimeout: Duration = IRC_TIMEOUT_SHORT_DELAY, + ): UserState? = withTimeoutOrNull(initialTimeout) { getLatestValidUserState(minChannelsSize) } ?: withTimeoutOrNull(fallbackTimeout) { getLatestValidUserState(minChannelsSize = 0) } 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 7ee277663..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 @@ -17,9 +17,15 @@ class UsersRepository { fun getUsersFlow(channel: UserName): StateFlow> = usersFlows.getOrPut(channel) { MutableStateFlow(emptySet()) } - fun findDisplayName(channel: UserName, userName: UserName): DisplayName? = users[channel]?.get(userName) + fun findDisplayName( + channel: UserName, + userName: UserName, + ): DisplayName? = users[channel]?.get(userName) - fun updateUsers(channel: UserName, new: List>) { + fun updateUsers( + channel: UserName, + new: List>, + ) { val current = users.getOrPut(channel) { LruCache(USER_CACHE_SIZE) } new.forEach { current.put(it.first, it.second) } @@ -28,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) @@ -37,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 @@ -51,7 +64,10 @@ class UsersRepository { usersFlows.remove(channel) } - fun cacheUserColor(userName: UserName, color: Int) { + fun cacheUserColor( + userName: UserName, + color: Int, + ) { userColors.put(userName, color) } 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 16c268746..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,6 +1,8 @@ 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"), 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 4f79468b7..3b01b40a0 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 @@ -74,15 +74,22 @@ class CommandRepository( } } - fun getCommandTriggers(channel: UserName): Flow> = when (channel) { - WhisperMessage.WHISPER_CHANNEL -> flowOf(TwitchCommandRepository.asCommandTriggers(TwitchCommand.Whisper.trigger)) - else -> commandTriggers - } + fun getCommandTriggers(channel: UserName): Flow> = + when (channel) { + WhisperMessage.WHISPER_CHANNEL -> flowOf(TwitchCommandRepository.asCommandTriggers(TwitchCommand.Whisper.trigger)) + else -> commandTriggers + } fun getSupibotCommands(channel: UserName): StateFlow> = supibotCommands.getOrPut(channel) { MutableStateFlow(emptyList()) } @Suppress("ReturnCount") - suspend fun checkForCommands(message: String, channel: UserName, roomState: RoomState, userState: UserState, skipSuspendingCommands: Boolean = false): CommandResult { + suspend fun checkForCommands( + message: String, + channel: UserName, + roomState: RoomState, + userState: UserState, + skipSuspendingCommands: Boolean = false, + ): CommandResult { if (!authDataStore.isLoggedIn) { return CommandResult.NotFound } @@ -126,7 +133,10 @@ class CommandRepository( return checkUserCommands(trigger) } - suspend fun checkForWhisperCommand(message: String, skipSuspendingCommands: Boolean): CommandResult { + suspend fun checkForWhisperCommand( + message: String, + skipSuspendingCommands: Boolean, + ): CommandResult { if (skipSuspendingCommands) { return CommandResult.Blocked } @@ -150,27 +160,28 @@ class CommandRepository( } } - suspend fun loadSupibotCommands() = withContext(Dispatchers.Default) { - if (!authDataStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { - return@withContext - } + suspend fun loadSupibotCommands() = + withContext(Dispatchers.Default) { + if (!authDataStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { + return@withContext + } - measureTimeMillis { - val channelsDeferred = async { getSupibotChannels() } - val commandsDeferred = async { getSupibotCommands() } - val aliasesDeferred = async { getSupibotUserAliases() } + measureTimeMillis { + val channelsDeferred = async { getSupibotChannels() } + val commandsDeferred = async { getSupibotCommands() } + val aliasesDeferred = async { getSupibotUserAliases() } - val channels = channelsDeferred.await() - val commands = commandsDeferred.await() - val aliases = aliasesDeferred.await() + val channels = channelsDeferred.await() + val commands = commandsDeferred.await() + val aliases = aliasesDeferred.await() - channels.forEach { - supibotCommands - .getOrPut(it) { MutableStateFlow(emptyList()) } - .update { commands + aliases } - } - }.let { Log.i(TAG, "Loaded Supibot commands in $it ms") } - } + channels.forEach { + supibotCommands + .getOrPut(it) { MutableStateFlow(emptyList()) } + .update { commands + aliases } + } + }.let { Log.i(TAG, "Loaded Supibot commands in $it ms") } + } private fun triggerAndArgsOrNull(message: String): Pair>? { val words = message.split(" ") @@ -186,21 +197,23 @@ class CommandRepository( return trigger to words.drop(1) } - 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 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 = authDataStore.userName ?: return emptyList() @@ -212,9 +225,10 @@ class CommandRepository( }.orEmpty() } - private fun clearSupibotCommands() = supibotCommands - .forEach { it.value.value = emptyList() } - .also { supibotCommands.clear() } + private fun clearSupibotCommands() = + supibotCommands + .forEach { it.value.value = emptyList() } + .also { supibotCommands.clear() } private suspend fun blockUserCommand(args: List): CommandResult.AcceptedWithResponse { if (args.isEmpty() || args.first().isBlank()) { @@ -273,7 +287,10 @@ class CommandRepository( return CommandResult.AcceptedWithResponse("Uptime: $uptime") } - private fun helpCommand(roomState: RoomState, userState: UserState): CommandResult.AcceptedWithResponse { + private fun helpCommand( + roomState: RoomState, + userState: UserState, + ): CommandResult.AcceptedWithResponse { val commands = twitchCommandRepository .getAvailableCommandTriggers(roomState, userState) 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 7a2b1e0b1..cf61bb90a 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 @@ -5,11 +5,18 @@ import com.flxrs.dankchat.data.twitch.command.TwitchCommand 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: String? = null, + ) : CommandResult + + data class AcceptedWithResponse( + val response: String, + ) : CommandResult + + data class Message( + val message: String, + ) : CommandResult data object NotFound : CommandResult 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 0c1f82a1f..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 @@ -36,23 +36,39 @@ sealed interface DataLoadingStep { override val displayNameRes = R.string.data_loading_step_twitch_emotes } - data class ChannelBadges(val channel: UserName, val channelId: UserId) : DataLoadingStep { + data class ChannelBadges( + val channel: UserName, + val channelId: UserId, + ) : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_channel_badges } - data class ChannelFFZEmotes(val channel: UserName, val channelId: UserId) : DataLoadingStep { + data class ChannelFFZEmotes( + val channel: UserName, + val channelId: UserId, + ) : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_ffz_emotes } - data class ChannelBTTVEmotes(val channel: UserName, val channelDisplayName: DisplayName, val channelId: UserId) : 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 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 { + data class ChannelCheermotes( + val channel: UserName, + val channelId: UserId, + ) : DataLoadingStep { override val displayNameRes = R.string.data_loading_step_cheermotes } } 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 fbf77795f..ea5b8557a 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 @@ -104,7 +104,10 @@ class DataRepository( 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() @@ -124,38 +127,48 @@ class DataRepository( } } - suspend fun uploadMedia(file: File): Result = uploadClient.uploadMedia(file).mapCatching { - recentUploadsRepository.addUpload(it) - it.imageLink - } - - suspend fun loadGlobalBadges(): Result = withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "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 uploadMedia(file: File): Result = + uploadClient.uploadMedia(file).mapCatching { + recentUploadsRepository.addUpload(it) + it.imageLink } - } - suspend fun loadDankChatBadges(): Result = withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "DankChat badges") { - dankChatApiClient - .getDankChatBadges() - .getOrEmitFailure { DataLoadingStep.DankChatBadges } - .onSuccess { emoteRepository.setDankChatBadges(it) } - .map { } + suspend fun loadGlobalBadges(): Result = + withContext(Dispatchers.IO) { + measureTimeAndLog(TAG, "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 loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result = emoteRepository - .loadUserEmotes(userId, onFirstPageLoaded) - .getOrEmitFailure { DataLoadingStep.TwitchEmotes } + suspend fun loadDankChatBadges(): Result = + withContext(Dispatchers.IO) { + measureTimeAndLog(TAG, "DankChat badges") { + dankChatApiClient + .getDankChatBadges() + .getOrEmitFailure { DataLoadingStep.DankChatBadges } + .onSuccess { emoteRepository.setDankChatBadges(it) } + .map { } + } + } - suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) { + suspend fun loadUserEmotes( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ): Result = + emoteRepository + .loadUserEmotes(userId, onFirstPageLoaded) + .getOrEmitFailure { DataLoadingStep.TwitchEmotes } + + suspend fun loadUserStateEmotes( + globalEmoteSetIds: List, + followerEmoteSetIds: Map>, + ) { emoteRepository.loadUserStateEmotes(globalEmoteSetIds, followerEmoteSetIds) } @@ -163,126 +176,151 @@ class DataRepository( serviceEventChannel.send(ServiceEvent.Shutdown) } - suspend fun loadChannelBadges(channel: UserName, id: UserId): Result = withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "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 loadChannelBadges( + channel: UserName, + id: UserId, + ): Result = + withContext(Dispatchers.IO) { + measureTimeAndLog(TAG, "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): Result = withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + suspend fun loadChannelFFZEmotes( + channel: UserName, + channelId: UserId, + ): Result = + withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "FFZ emotes for #$channel") { - ffzApiClient - .getFFZChannelEmotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } - .onSuccess { it?.let { emoteRepository.setFFZEmotes(channel, it) } } - .map { } + measureTimeAndLog(TAG, "FFZ emotes for #$channel") { + ffzApiClient + .getFFZChannelEmotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } + .onSuccess { it?.let { emoteRepository.setFFZEmotes(channel, it) } } + .map { } + } } - } - suspend fun loadChannelBTTVEmotes(channel: UserName, channelDisplayName: DisplayName, channelId: UserId): Result = withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + suspend fun loadChannelBTTVEmotes( + channel: UserName, + channelDisplayName: DisplayName, + channelId: UserId, + ): Result = + withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "BTTV emotes for #$channel") { - bttvApiClient - .getBTTVChannelEmotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } - .onSuccess { it?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } - .map { } + measureTimeAndLog(TAG, "BTTV emotes for #$channel") { + bttvApiClient + .getBTTVChannelEmotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } + .onSuccess { it?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } + .map { } + } } - } - suspend fun loadChannelSevenTVEmotes(channel: UserName, channelId: UserId): Result = withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + suspend fun loadChannelSevenTVEmotes( + channel: UserName, + channelId: UserId, + ): Result = + withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "7TV emotes for #$channel") { - sevenTVApiClient - .getSevenTVChannelEmotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelSevenTVEmotes(channel, channelId) } - .onSuccess { result -> - result ?: return@onSuccess - if (result.emoteSet?.id != null) { - sevenTVEventApiClient.subscribeEmoteSet(result.emoteSet.id) - } - sevenTVEventApiClient.subscribeUser(result.user.id) - emoteRepository.setSevenTVEmotes(channel, result) - }.map { } + measureTimeAndLog(TAG, "7TV emotes for #$channel") { + sevenTVApiClient + .getSevenTVChannelEmotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelSevenTVEmotes(channel, channelId) } + .onSuccess { result -> + result ?: return@onSuccess + if (result.emoteSet?.id != null) { + sevenTVEventApiClient.subscribeEmoteSet(result.emoteSet.id) + } + sevenTVEventApiClient.subscribeUser(result.user.id) + emoteRepository.setSevenTVEmotes(channel, result) + }.map { } + } } - } - suspend fun loadChannelCheermotes(channel: UserName, channelId: UserId): Result = withContext(Dispatchers.IO) { - if (!authDataStore.isLoggedIn) { - return@withContext Result.success(Unit) - } + suspend fun loadChannelCheermotes( + channel: UserName, + channelId: UserId, + ): Result = + withContext(Dispatchers.IO) { + if (!authDataStore.isLoggedIn) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "cheermotes for #$channel") { - helixApiClient - .getCheermotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } - .onSuccess { emoteRepository.setCheermotes(channel, it) } - .map { } + measureTimeAndLog(TAG, "cheermotes for #$channel") { + helixApiClient + .getCheermotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } + .onSuccess { emoteRepository.setCheermotes(channel, it) } + .map { } + } } - } - suspend fun loadGlobalFFZEmotes(): Result = withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + suspend fun loadGlobalFFZEmotes(): Result = + withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "global FFZ emotes") { - ffzApiClient - .getFFZGlobalEmotes() - .getOrEmitFailure { DataLoadingStep.GlobalFFZEmotes } - .onSuccess { emoteRepository.setFFZGlobalEmotes(it) } - .map { } + measureTimeAndLog(TAG, "global FFZ emotes") { + ffzApiClient + .getFFZGlobalEmotes() + .getOrEmitFailure { DataLoadingStep.GlobalFFZEmotes } + .onSuccess { emoteRepository.setFFZGlobalEmotes(it) } + .map { } + } } - } - suspend fun loadGlobalBTTVEmotes(): Result = withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + suspend fun loadGlobalBTTVEmotes(): Result = + withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "global BTTV emotes") { - bttvApiClient - .getBTTVGlobalEmotes() - .getOrEmitFailure { DataLoadingStep.GlobalBTTVEmotes } - .onSuccess { emoteRepository.setBTTVGlobalEmotes(it) } - .map { } + measureTimeAndLog(TAG, "global BTTV emotes") { + bttvApiClient + .getBTTVGlobalEmotes() + .getOrEmitFailure { DataLoadingStep.GlobalBTTVEmotes } + .onSuccess { emoteRepository.setBTTVGlobalEmotes(it) } + .map { } + } } - } - suspend fun loadGlobalSevenTVEmotes(): Result = withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + suspend fun loadGlobalSevenTVEmotes(): Result = + withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "global 7TV emotes") { - sevenTVApiClient - .getSevenTVGlobalEmotes() - .getOrEmitFailure { DataLoadingStep.GlobalSevenTVEmotes } - .onSuccess { emoteRepository.setSevenTVGlobalEmotes(it) } - .map { } + measureTimeAndLog(TAG, "global 7TV emotes") { + sevenTVApiClient + .getSevenTVGlobalEmotes() + .getOrEmitFailure { DataLoadingStep.GlobalSevenTVEmotes } + .onSuccess { emoteRepository.setSevenTVGlobalEmotes(it) } + .map { } + } } - } - private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = onFailure { throwable -> - Log.e(TAG, "Data request failed:", throwable) - _dataLoadingFailures.update { it + DataLoadingFailure(step(), throwable) } - } + private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = + onFailure { throwable -> + Log.e(TAG, "Data request failed:", throwable) + _dataLoadingFailures.update { it + DataLoadingFailure(step(), throwable) } + } companion object { private val TAG = DataRepository::class.java.simpleName 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 dadbcac2a..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,7 +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 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 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 index 1a07c6f46..f52ee3cae 100644 --- 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 @@ -16,10 +16,17 @@ import org.koin.core.annotation.Single @Immutable @Serializable -data class EmojiData(val code: String, val unicode: String) +data class EmojiData( + val code: String, + val unicode: String, +) @Single -class EmojiRepository(private val context: Context, private val json: Json, dispatchersProvider: DispatchersProvider) { +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() 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 69bb9fef0..d0539d32e 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 @@ -102,7 +102,11 @@ class EmoteRepository( } } - fun parse3rdPartyEmotes(message: String, channel: UserName, withTwitch: Boolean = false): List { + fun parse3rdPartyEmotes( + message: String, + channel: UserName, + withTwitch: Boolean = false, + ): List { val globalState = globalEmoteState.value val channelState = channelEmoteStates[channel]?.value ?: ChannelEmoteState() val isWhisper = channel == WhisperMessage.WHISPER_CHANNEL @@ -194,11 +198,11 @@ class EmoteRepository( is UserNoticeMessage -> { message.copy( childMessage = - message.childMessage?.copy( - message = adjustedMessage, - emotes = adjustedEmotes, - originalMessage = withEmojiFix, - ), + message.childMessage?.copy( + message = adjustedMessage, + emotes = adjustedEmotes, + originalMessage = withEmojiFix, + ), ) } @@ -305,20 +309,29 @@ class EmoteRepository( } } - data class TagListEntry(val key: String, val value: String, val tag: String) + data class TagListEntry( + val key: String, + val value: String, + val tag: String, + ) + + private fun String.parseTagList(): List = + split(',') + .mapNotNull { + if (!it.contains('/')) { + return@mapNotNull null + } - private fun String.parseTagList(): List = split(',') - .mapNotNull { - if (!it.contains('/')) { - return@mapNotNull null + val key = it.substringBefore('/') + val value = it.substringAfter('/') + TagListEntry(key, value, it) } - val key = it.substringBefore('/') - val value = it.substringAfter('/') - TagListEntry(key, value, it) - } - - private fun getChannelBadgeUrl(channel: UserName?, set: String, version: String) = channel?.let { + private fun getChannelBadgeUrl( + channel: UserName?, + set: String, + version: String, + ) = channel?.let { channelBadges[channel] ?.get(set) ?.versions @@ -326,16 +339,24 @@ class EmoteRepository( ?.imageUrlHigh } - 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 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] } @@ -359,7 +380,10 @@ class EmoteRepository( ) } - fun setChannelBadges(channel: UserName, badges: Map) { + fun setChannelBadges( + channel: UserName, + badges: Map, + ) { channelBadges[channel] = badges } @@ -371,18 +395,26 @@ class EmoteRepository( dankChatBadges.addAll(dto) } - fun getChannelForSevenTVEmoteSet(emoteSetId: String): UserName? = sevenTvChannelDetails - .entries - .find { (_, details) -> details.activeEmoteSetId == emoteSetId } - ?.key + fun getChannelForSevenTVEmoteSet(emoteSetId: String): UserName? = + sevenTvChannelDetails + .entries + .find { (_, details) -> details.activeEmoteSetId == emoteSetId } + ?.key fun getSevenTVUserDetails(channel: UserName): SevenTVUserDetails? = sevenTvChannelDetails[channel] - suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result = runCatching { - loadUserEmotesViaHelix(userId, onFirstPageLoaded) - } + suspend fun loadUserEmotes( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ): Result = + runCatching { + loadUserEmotesViaHelix(userId, onFirstPageLoaded) + } - private suspend fun loadUserEmotesViaHelix(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null) = withContext(Dispatchers.Default) { + private suspend fun loadUserEmotesViaHelix( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ) = withContext(Dispatchers.Default) { val seenIds = mutableSetOf() val allEmotes = mutableListOf() var totalCount = 0 @@ -458,7 +490,10 @@ class EmoteRepository( Log.d(TAG, "Helix getUserEmotes: $totalCount total, ${seenIds.size} unique, ${allEmotes.size} resolved") } - suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) = withContext(Dispatchers.Default) { + suspend fun loadUserStateEmotes( + globalEmoteSetIds: List, + followerEmoteSetIds: Map>, + ) = withContext(Dispatchers.Default) { val sets = (globalEmoteSetIds + followerEmoteSetIds.values.flatten()) .distinct() @@ -504,7 +539,10 @@ class EmoteRepository( } } - suspend fun setFFZEmotes(channel: UserName, ffzResult: FFZChannelDto) = withContext(Dispatchers.Default) { + suspend fun setFFZEmotes( + channel: UserName, + ffzResult: FFZChannelDto, + ) = withContext(Dispatchers.Default) { val ffzEmotes = ffzResult.sets .flatMap { set -> @@ -525,31 +563,40 @@ class EmoteRepository( } } - 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(Dispatchers.Default) { + val ffzGlobalEmotes = + ffzResult.sets + .filter { it.key in ffzResult.defaultSets } + .flatMap { (_, emoteSet) -> + emoteSet.emotes.mapNotNull { emote -> + parseFFZEmote(emote, channel = null) + } } - } - globalEmoteState.update { it.copy(ffzEmotes = 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(Dispatchers.Default) { val bttvEmotes = (bttvResult.emotes + bttvResult.sharedEmotes).map { parseBTTVEmote(it, channelDisplayName) } channelEmoteStates[channel]?.update { it.copy(bttvEmotes = bttvEmotes) } } - suspend fun setBTTVGlobalEmotes(globalEmotes: List) = withContext(Dispatchers.Default) { - val bttvGlobalEmotes = globalEmotes.map { parseBTTVGlobalEmote(it) } - globalEmoteState.update { it.copy(bttvEmotes = bttvGlobalEmotes) } - } + suspend fun setBTTVGlobalEmotes(globalEmotes: List) = + withContext(Dispatchers.Default) { + val bttvGlobalEmotes = globalEmotes.map { parseBTTVGlobalEmote(it) } + globalEmoteState.update { it.copy(bttvEmotes = bttvGlobalEmotes) } + } - suspend fun setSevenTVEmotes(channel: UserName, userDto: SevenTVUserDto) = withContext(Dispatchers.Default) { + suspend fun setSevenTVEmotes( + channel: UserName, + userDto: SevenTVUserDto, + ) = withContext(Dispatchers.Default) { val emoteSetId = userDto.emoteSet?.id ?: return@withContext val emoteList = userDto.emoteSet.emotes.orEmpty() @@ -571,7 +618,10 @@ class EmoteRepository( } } - suspend fun setSevenTVEmoteSet(channel: UserName, emoteSet: SevenTVEmoteSetDto) = withContext(Dispatchers.Default) { + suspend fun setSevenTVEmoteSet( + channel: UserName, + emoteSet: SevenTVEmoteSetDto, + ) = withContext(Dispatchers.Default) { sevenTvChannelDetails[channel]?.let { details -> sevenTvChannelDetails[channel] = details.copy(activeEmoteSetId = emoteSet.id) } @@ -589,7 +639,10 @@ class EmoteRepository( } } - suspend fun updateSevenTVEmotes(channel: UserName, event: SevenTVEventMessage.EmoteSetUpdated) = withContext(Dispatchers.Default) { + suspend fun updateSevenTVEmotes( + channel: UserName, + event: SevenTVEventMessage.EmoteSetUpdated, + ) = withContext(Dispatchers.Default) { val addedEmotes = event.added .filterUnlistedIfEnabled() @@ -620,45 +673,49 @@ class EmoteRepository( } } - suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = withContext(Dispatchers.Default) { - if (sevenTvResult.isEmpty()) return@withContext + suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = + withContext(Dispatchers.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) } - } + globalEmoteState.update { it.copy(sevenTvEmotes = sevenTvGlobalEmotes) } + } - suspend fun setCheermotes(channel: UserName, cheermoteDtos: List) = withContext(Dispatchers.Default) { + suspend fun setCheermotes( + channel: UserName, + cheermoteDtos: List, + ) = withContext(Dispatchers.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(), - ) - }, + 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 { @@ -666,7 +723,10 @@ class EmoteRepository( } } - private fun parseCheermotes(message: String, channel: UserName): List { + private fun parseCheermotes( + message: String, + channel: UserName, + ): List { val cheermoteSets = channelEmoteStates[channel]?.value?.cheermoteSets if (cheermoteSets.isNullOrEmpty()) return emptyList() @@ -723,25 +783,29 @@ class EmoteRepository( ) } - private fun List?.mapToGenericEmotes(type: EmoteType): List = this - ?.map { (name, id) -> - val code = - when (type) { - is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name - else -> name - } - GenericEmote( - code = code, - url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), - lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), - id = id, - scale = 1, - emoteType = type, - ) - }.orEmpty() + private fun List?.mapToGenericEmotes(type: EmoteType): List = + this + ?.map { (name, id) -> + val code = + when (type) { + is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name + else -> name + } + GenericEmote( + code = code, + url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), + lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), + id = id, + scale = 1, + 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() @@ -802,7 +866,10 @@ class EmoteRepository( /** * Counts elements in a sorted list that are strictly less than [value] using binary search. */ - private fun countLessThan(sortedList: List, value: Int): Int { + private fun countLessThan( + sortedList: List, + value: Int, + ): Int { var low = 0 var high = sortedList.size while (low < high) { @@ -819,30 +886,34 @@ class EmoteRepository( appendedSpaces: List, removedSpaces: List, replyMentionOffset: Int, - ): List = emotesWithPositions.flatMap { (id, positions) -> - positions.map { range -> - val removedSpaceExtra = countLessThan(removedSpaces, range.first) - val unicodeExtra = countLessThan(supplementaryCodePointPositions, range.first - removedSpaceExtra) - val spaceExtra = countLessThan(appendedSpaces, range.first + unicodeExtra) - val fixedStart = range.first + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset - val fixedEnd = range.last + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset - - // 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), - id = id, - code = code, - scale = 1, - type = ChatMessageEmoteType.TwitchEmote, - isTwitch = true, - ) + ): List = + emotesWithPositions.flatMap { (id, positions) -> + positions.map { range -> + val removedSpaceExtra = countLessThan(removedSpaces, range.first) + val unicodeExtra = countLessThan(supplementaryCodePointPositions, range.first - removedSpaceExtra) + val spaceExtra = countLessThan(appendedSpaces, range.first + unicodeExtra) + val fixedStart = range.first + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset + val fixedEnd = range.last + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset + + // 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), + id = id, + code = code, + scale = 1, + type = ChatMessageEmoteType.TwitchEmote, + 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) @@ -874,7 +945,10 @@ 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 ?: emote.urls @@ -895,7 +969,10 @@ class EmoteRepository( 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 @@ -923,10 +1000,11 @@ class EmoteRepository( 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 } - } + private suspend fun List.filterUnlistedIfEnabled(): List = + when { + chatSettingsDataStore.settings.first().allowUnlistedSevenTvEmotes -> this + else -> filter { it.data?.listed == true } + } private val String.withLeadingHttps: String get() = 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 9eb2332bd..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 @@ -14,7 +14,10 @@ import org.koin.core.annotation.Single import java.time.Instant @Single -class EmoteUsageRepository(private val emoteUsageDao: EmoteUsageDao, dispatchersProvider: DispatchersProvider) { +class EmoteUsageRepository( + private val emoteUsageDao: EmoteUsageDao, + dispatchersProvider: DispatchersProvider, +) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) val recentEmoteIds: StateFlow> = 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 d3b9d01ba..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 @@ -18,7 +18,10 @@ data class ChannelEmoteState( val cheermoteSets: List = emptyList(), ) -fun mergeEmotes(global: GlobalEmoteState, channel: ChannelEmoteState): Emotes { +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 } 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 index 770c716c0..4d71096dc 100644 --- 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 @@ -2,4 +2,10 @@ 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) +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/state/ChannelLoadingState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt index 893102627..530d64899 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -12,24 +12,45 @@ sealed interface ChannelLoadingState { data object Loaded : ChannelLoadingState - data class Failed(val failures: List) : 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 + 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 { @@ -39,5 +60,8 @@ sealed interface GlobalLoadingState { data object Loaded : GlobalLoadingState - data class Failed(val failures: Set = emptySet(), val chatFailures: Set = emptySet()) : 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 6df7447e6..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 @@ -12,5 +12,10 @@ sealed interface DataLoadingState { data object Loading : DataLoadingState - data class Failed(val errorMessage: String, val errorCount: Int, val dataFailures: Set, val chatFailures: Set) : DataLoadingState + data class Failed( + val errorMessage: String, + val errorCount: Int, + val dataFailures: 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 6379cc502..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 @@ -7,7 +7,13 @@ sealed interface ImageUploadState { data object Loading : ImageUploadState - data class Finished(val url: String) : ImageUploadState + data class Finished( + val url: String, + ) : ImageUploadState - data class Failed(val errorMessage: String?, val mediaFile: File, val imageCapture: Boolean) : 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 e04881774..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,15 +11,45 @@ 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 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 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 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 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 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, 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 ae166a253..e66b1a256 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 @@ -3,40 +3,51 @@ package com.flxrs.dankchat.data.twitch.badge import com.flxrs.dankchat.data.api.badges.dto.TwitchBadgeSetsDto import com.flxrs.dankchat.data.api.helix.dto.BadgeSetDto -data class BadgeSet(val id: String, val versions: Map) +data class BadgeSet( + val id: String, + val versions: Map, +) -data class BadgeVersion(val id: String, val title: String, val imageUrlLow: String, val imageUrlMedium: String, val imageUrlHigh: String) +data class BadgeVersion( + val id: String, + val title: String, + val imageUrlLow: String, + val imageUrlMedium: 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, - ) - }, - ) -} - -fun List.toBadgeSets(): Map = associate { (id, versions) -> - id to +fun TwitchBadgeSetsDto.toBadgeSets(): Map = + sets.mapValues { (id, set) -> BadgeSet( id = id, versions = - versions.associate { badge -> - badge.id to + set.versions.mapValues { (badgeId, badge) -> BadgeVersion( - id = badge.id, + 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, + ) + }, + ) + } 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 6203069d2..f15be2a93 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 @@ -13,12 +13,13 @@ enum class BadgeType { // FrankerFaceZ; companion object { - fun parseFromBadgeId(id: String): BadgeType = when (id) { - "staff", "admin", "global_admin" -> Authority - "predictions" -> Predictions - "lead_moderator", "moderator", "vip", "broadcaster" -> Channel - "subscriber", "founder" -> Subscriber - else -> Vanity - } + fun parseFromBadgeId(id: String): BadgeType = + when (id) { + "staff", "admin", "global_admin" -> Authority + "predictions" -> Predictions + "lead_moderator", "moderator", "vip", "broadcaster" -> Channel + "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 c43380538..a4a6bc001 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 @@ -44,13 +44,22 @@ enum class ChatConnectionType { } sealed interface ChatEvent { - data class Message(val message: IrcMessage) : ChatEvent + data class Message( + val message: IrcMessage, + ) : ChatEvent - data class Connected(val channel: UserName, val isAnonymous: Boolean) : ChatEvent + data class Connected( + val channel: UserName, + val isAnonymous: Boolean, + ) : ChatEvent - data class ChannelNonExistent(val channel: UserName) : ChatEvent + data class ChannelNonExistent( + val channel: UserName, + ) : ChatEvent - data class Error(val throwable: Throwable) : ChatEvent + data class Error( + val throwable: Throwable, + ) : ChatEvent data object LoginFailed : ChatEvent @@ -314,39 +323,41 @@ class ChatConnection( private fun randomJitter() = Random.nextLong(range = 0L..MAX_JITTER).milliseconds - private fun setupPingInterval() = scope.timer(interval = PING_INTERVAL - randomJitter()) { - val currentSession = session - if (awaitingPong || currentSession?.isActive != true) { - cancel() - reconnect() - return@timer - } + private fun setupPingInterval() = + scope.timer(interval = PING_INTERVAL - randomJitter()) { + val currentSession = session + if (awaitingPong || currentSession?.isActive != true) { + cancel() + reconnect() + return@timer + } - if (_connected.value) { - awaitingPong = true - runCatching { currentSession.send(Frame.Text("PING\r\n")) } + if (_connected.value) { + awaitingPong = true + 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") - if (session?.isActive != true || !_connected.value || channelsAttemptedToJoin.isEmpty()) { - return@launch - } + private fun setupJoinCheckInterval(channelsToCheck: List) = + scope.launch { + Log.d(TAG, "[$chatConnectionType] setting up join check for $channelsToCheck") + if (session?.isActive != true || !_connected.value || channelsAttemptedToJoin.isEmpty()) { + return@launch + } - delay(JOIN_CHECK_DELAY) - if (session?.isActive != true || !_connected.value) { - channelsAttemptedToJoin.removeAll(channelsToCheck.toSet()) - return@launch - } + delay(JOIN_CHECK_DELAY) + if (session?.isActive != true || !_connected.value) { + channelsAttemptedToJoin.removeAll(channelsToCheck.toSet()) + return@launch + } - channelsToCheck.forEach { - if (it in channelsAttemptedToJoin) { - channelsAttemptedToJoin.remove(it) - receiveChannel.send(ChatEvent.ChannelNonExistent(it)) + channelsToCheck.forEach { + if (it in channelsAttemptedToJoin) { + channelsAttemptedToJoin.remove(it) + receiveChannel.send(ChatEvent.ChannelNonExistent(it)) + } } } - } private suspend fun DefaultClientWebSocketSession.sendIrc(msg: String) { send(Frame.Text("${msg.trimEnd()}\r\n")) 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 8130afde6..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 @@ -4,4 +4,11 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.message.RoomState -data class CommandContext(val trigger: String, val channel: UserName, val channelId: UserId, val roomState: RoomState, val originalMessage: String, val args: List) +data class CommandContext( + val trigger: String, + val channel: UserName, + val channelId: UserId, + val roomState: RoomState, + val originalMessage: String, + 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 9688dd5a9..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"), 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 efa9855bb..746fceeaa 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 @@ -25,10 +25,16 @@ import org.koin.core.annotation.Single import java.util.UUID @Single -class TwitchCommandRepository(private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore) { +class TwitchCommandRepository( + private val helixApiClient: HelixApiClient, + private val authDataStore: AuthDataStore, +) { fun isIrcCommand(trigger: String): Boolean = trigger in ALLOWED_IRC_COMMAND_TRIGGERS - fun getAvailableCommandTriggers(room: RoomState, userState: UserState): List { + fun getAvailableCommandTriggers( + room: RoomState, + userState: UserState, + ): List { val currentUserId = authDataStore.userIdString ?: return emptyList() return when { room.channelId == currentUserId -> TwitchCommand.ALL_COMMANDS @@ -48,7 +54,10 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat return TwitchCommand.ALL_COMMANDS.find { it.trigger == withoutFirstChar } } - suspend fun handleTwitchCommand(command: TwitchCommand, context: CommandContext): CommandResult { + suspend fun handleTwitchCommand( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val currentUserId = authDataStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( command = command, @@ -131,7 +140,12 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat } } - 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 .") } @@ -152,7 +166,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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.") @@ -178,28 +196,35 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - private suspend fun getModerators(command: TwitchCommand, context: CommandContext): CommandResult = helixApiClient.getModerators(context.channelId).fold( - onSuccess = { result -> - when { - result.isEmpty() -> { - CommandResult.AcceptedTwitchCommand(command, response = "This channel does not have any moderators.") - } + private suspend fun getModerators( + command: TwitchCommand, + context: CommandContext, + ): CommandResult = + 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.") + else -> { + val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } + CommandResult.AcceptedTwitchCommand(command, response = "The moderators of this channel are $users.") + } } - } - 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) - }, - ) + 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 { + 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.") @@ -221,7 +246,10 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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.") @@ -243,26 +271,33 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - private suspend fun getVips(command: TwitchCommand, context: CommandContext): CommandResult = helixApiClient.getVips(context.channelId).fold( - onSuccess = { result -> - when { - result.isEmpty() -> { - CommandResult.AcceptedTwitchCommand(command, response = "This channel does not have any VIPs.") - } + private suspend fun getVips( + command: TwitchCommand, + context: CommandContext, + ): CommandResult = + 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.") + else -> { + val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } + CommandResult.AcceptedTwitchCommand(command, response = "The vips of this channel are $users.") + } } - } - }, - onFailure = { - val response = "Failed to list VIPs - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) - }, - ) + }, + onFailure = { + val response = "Failed to list VIPs - ${it.toErrorMessage(command)}" + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) - private suspend fun addVip(command: TwitchCommand, context: CommandContext): CommandResult { + 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.") @@ -284,7 +319,10 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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.") @@ -306,7 +344,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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 = @@ -340,7 +382,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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." @@ -362,7 +408,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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] - " + @@ -405,15 +455,24 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - private suspend fun clearChat(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult = 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 clearChat( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult = + 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 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.") @@ -434,7 +493,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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." @@ -453,7 +516,10 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - private suspend fun createMarker(command: TwitchCommand, context: CommandContext): CommandResult { + private suspend fun createMarker( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val description = context.args .joinToString(separator = " ") @@ -474,7 +540,10 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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." if (args.isEmpty() || args.first().isBlank()) { @@ -498,7 +567,10 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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." @@ -519,15 +591,23 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - private suspend fun cancelRaid(command: TwitchCommand, context: CommandContext): CommandResult = helixApiClient.deleteRaid(context.channelId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You cancelled the raid.") }, - onFailure = { - val response = "Failed to cancel the raid - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) - }, - ) + private suspend fun cancelRaid( + command: TwitchCommand, + context: CommandContext, + ): CommandResult = + helixApiClient.deleteRaid(context.channelId).fold( + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You cancelled the raid.") }, + onFailure = { + val response = "Failed to cancel the raid - ${it.toErrorMessage(command)}" + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) - private suspend fun enableFollowersMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + 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). " + @@ -552,7 +632,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat } } - 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.") } @@ -561,7 +645,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat 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.") } @@ -570,7 +658,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat 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.") } @@ -579,7 +671,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat 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.") } @@ -588,7 +684,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat 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.") } @@ -597,7 +697,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat 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.") } @@ -606,7 +710,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat 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.") } @@ -615,7 +723,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat 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) { @@ -635,7 +747,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat } } - 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.") } @@ -650,15 +766,20 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat context: CommandContext, request: ChatSettingsRequestDto, formatRange: ((IntRange) -> String)? = null, - ): CommandResult = 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 { + ): CommandResult = + 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.") @@ -678,7 +799,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - 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) @@ -698,7 +823,11 @@ class TwitchCommandRepository(private val helixApiClient: HelixApiClient, privat ) } - private fun Throwable.toErrorMessage(command: TwitchCommand, targetUser: DisplayName? = null, formatRange: ((IntRange) -> String)? = null): String { + private fun Throwable.toErrorMessage( + command: TwitchCommand, + targetUser: DisplayName? = null, + formatRange: ((IntRange) -> String)? = null, + ): String { Log.v(TAG, "Command failed: $this") if (this !is HelixApiException) { return GENERIC_ERROR_MESSAGE 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 4b2851c53..0e09d40e2 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 @@ -9,33 +9,47 @@ sealed interface ChatMessageEmoteType : Parcelable { 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 + data class GlobalSevenTVEmote( + val creator: DisplayName?, + val baseName: String?, + ) : ChatMessageEmoteType @Parcelize 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.ChannelSevenTVEmote -> ChatMessageEmoteType.ChannelSevenTVEmote(creator, baseName) - EmoteType.GlobalBTTVEmote -> ChatMessageEmoteType.GlobalBTTVEmote - is EmoteType.GlobalFFZEmote -> ChatMessageEmoteType.GlobalFFZEmote(creator) - is EmoteType.GlobalSevenTVEmote -> ChatMessageEmoteType.GlobalSevenTVEmote(creator, baseName) - else -> null -} +fun EmoteType.toChatMessageEmoteType(): ChatMessageEmoteType? = + when (this) { + 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 + } 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 index 4200df2c9..5dcfe1d0a 100644 --- 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 @@ -2,6 +2,15 @@ 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 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) +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 4ef2c2db1..9bab5543c 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" } @@ -50,23 +69,24 @@ sealed interface EmoteType : Comparable { override val title = "" } - override fun compareTo(other: EmoteType): Int = when { - this is ChannelTwitchBitEmote || this is ChannelTwitchFollowerEmote -> { - when (other) { - is ChannelTwitchBitEmote, - is ChannelTwitchFollowerEmote, - -> 0 + override fun compareTo(other: EmoteType): Int = + when { + this is ChannelTwitchBitEmote || this is ChannelTwitchFollowerEmote -> { + when (other) { + is ChannelTwitchBitEmote, + is ChannelTwitchFollowerEmote, + -> 0 - else -> 1 + else -> 1 + } } - } - other is ChannelTwitchBitEmote || other is ChannelTwitchFollowerEmote -> { - -1 - } + other is ChannelTwitchBitEmote || other is ChannelTwitchFollowerEmote -> { + -1 + } - else -> { - 0 + 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 73a9d805c..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 @@ -1,7 +1,14 @@ package com.flxrs.dankchat.data.twitch.emote -data class GenericEmote(val code: String, val url: String, val lowResUrl: String, val id: String, val scale: Int, val emoteType: EmoteType, val isOverlayEmote: Boolean = false) : - Comparable { +data class GenericEmote( + val code: String, + val url: String, + val lowResUrl: String, + val id: String, + val scale: Int, + val emoteType: EmoteType, + val isOverlayEmote: Boolean = false, +) : Comparable { override fun toString(): String = code 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 acd4e449b..35c56bd90 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 @@ -7,9 +7,10 @@ enum class ThirdPartyEmoteType { ; 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/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 2fd4e2c78..bdd5cbbcc 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 @@ -1,6 +1,9 @@ package com.flxrs.dankchat.data.twitch.message -data class Highlight(val type: HighlightType, val customColor: Int? = null) { +data class Highlight( + val type: HighlightType, + val customColor: Int? = null, +) { val isMention = type in MENTION_TYPES val shouldNotify = type == HighlightType.Notification @@ -15,7 +18,9 @@ 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), ChannelPointRedemption(HighlightPriority.HIGH), @@ -28,7 +33,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 0dd104033..5c9083c26 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 @@ -10,9 +10,18 @@ sealed class Message { abstract val timestamp: Long abstract val highlights: Set - data class EmoteData(val message: String, val channel: UserName, val emotesWithPositions: List) + 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 BadgeData( + val userId: UserId?, + val channel: UserName?, + val badgeTag: String?, + val badgeInfoTag: String?, + ) open val emoteData: EmoteData? = null open val badgeData: BadgeData? = null @@ -21,16 +30,23 @@ sealed class Message { private const val DEFAULT_COLOR_TAG = "#717171" val DEFAULT_COLOR = DEFAULT_COLOR_TAG.toColorInt() - fun parse(message: IrcMessage, findChannel: (UserId) -> UserName?): Message? = with(message) { - return when (command) { - "PRIVMSG" -> PrivMessage.parsePrivMessage(message, findChannel) - "NOTICE" -> NoticeMessage.parseNotice(message) - "USERNOTICE" -> UserNoticeMessage.parseUserNotice(message, findChannel) - else -> null + fun parse( + message: IrcMessage, + findChannel: (UserId) -> UserName?, + ): Message? = + with(message) { + return when (command) { + "PRIVMSG" -> PrivMessage.parsePrivMessage(message, findChannel) + "NOTICE" -> NoticeMessage.parseNotice(message) + "USERNOTICE" -> UserNoticeMessage.parseUserNotice(message, findChannel) + else -> null + } } - } - fun parseEmoteTag(message: String, tag: String): List { + fun parseEmoteTag( + message: String, + tag: String, + ): List { return tag.split('/').mapNotNull { emote -> val split = emote.split(':') // bad emote data :) 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 f6839ab6c..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 @@ -1,3 +1,8 @@ package com.flxrs.dankchat.data.twitch.message -data class MessageThread(val rootMessageId: String, val rootMessage: PrivMessage, val replies: List, val participated: Boolean) +data class MessageThread( + val rootMessageId: String, + val rootMessage: PrivMessage, + val replies: List, + 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 1234cbf27..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 @@ -2,4 +2,9 @@ package com.flxrs.dankchat.data.twitch.message import com.flxrs.dankchat.data.UserName -data class MessageThreadHeader(val rootId: String, val name: UserName, val message: String, val participated: Boolean) +data class MessageThreadHeader( + val rootId: String, + val name: UserName, + val message: String, + 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 663733666..40fbab34a 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 @@ -83,10 +83,11 @@ data class ModerationMessage( }.takeIf { it.isNotEmpty() } } - private fun countSuffix(): TextResource = when { - stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) - else -> TextResource.Plain("") - } + private fun countSuffix(): TextResource = + when { + stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) + else -> TextResource.Plain("") + } private fun formatMinutesDuration(minutes: Int): TextResource { val parts = DateTimeUtils.decomposeMinutes(minutes).map { it.toTextResource() } @@ -110,14 +111,21 @@ data class ModerationMessage( 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])) - } + 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 getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): TextResource { + fun getSystemMessage( + currentUser: UserName?, + showDeletedMessage: Boolean, + ): TextResource { val creator = creatorUserDisplay.toString() val target = targetUserDisplay.toString() val dur = duration.orEmpty() @@ -360,56 +368,62 @@ data class ModerationMessage( val canStack: Boolean = canClearMessages && action != Action.Clear companion object { - 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"] ?: "clearchat-$ts-$channel-${target ?: "all"}" - val action = - when { - target == null -> Action.Clear - durationSeconds == null -> Action.Ban - else -> Action.Timeout - } - - return ModerationMessage( - timestamp = ts, - id = id, - channel = channel.toUserName(), - action = action, - targetUserDisplay = target?.toDisplayName(), - targetUser = target?.toUserName(), - durationInt = durationSeconds, - duration = duration, - stackCount = if (target != null && duration != null) 1 else 0, - fromEventSource = false, - ) - } + 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"] ?: "clearchat-$ts-$channel-${target ?: "all"}" + val action = + when { + target == null -> Action.Clear + durationSeconds == null -> Action.Ban + else -> Action.Timeout + } - fun parseClearMessage(message: IrcMessage): ModerationMessage = with(message) { - val channel = params[0].substring(1) - val target = tags["login"] - val targetMsgId = tags["target-msg-id"] - val reason = params.getOrNull(1) - val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val id = tags["id"] ?: "clearmsg-$ts-$channel-${target ?: ""}" + return ModerationMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + action = action, + targetUserDisplay = target?.toDisplayName(), + targetUser = target?.toUserName(), + durationInt = durationSeconds, + duration = duration, + stackCount = if (target != null && duration != null) 1 else 0, + fromEventSource = false, + ) + } - return ModerationMessage( - timestamp = ts, - id = id, - channel = channel.toUserName(), - action = Action.Delete, - targetUserDisplay = target?.toDisplayName(), - targetUser = target?.toUserName(), - targetMsgId = targetMsgId, - reason = reason, - fromEventSource = false, - ) - } + fun parseClearMessage(message: IrcMessage): ModerationMessage = + with(message) { + val channel = params[0].substring(1) + val target = tags["login"] + val targetMsgId = tags["target-msg-id"] + val reason = params.getOrNull(1) + val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() + val id = tags["id"] ?: "clearmsg-$ts-$channel-${target ?: ""}" + + return ModerationMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + action = Action.Delete, + targetUserDisplay = target?.toDisplayName(), + targetUser = target?.toUserName(), + targetMsgId = targetMsgId, + reason = reason, + fromEventSource = false, + ) + } - 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) @@ -434,7 +448,12 @@ data class ModerationMessage( ) } - 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) @@ -460,135 +479,151 @@ data class ModerationMessage( ) } - private fun parseDuration(seconds: Int?, data: ModerationActionData): String? = when (data.moderationAction) { - ModerationActionType.Timeout -> seconds?.let { DateTimeUtils.formatSeconds(seconds) } - else -> null - } + 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() - ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.let { it.expiresAt.epochSeconds - timestamp.epochSeconds }?.toInt() - ChannelModerateAction.Followers -> data.followers?.followDurationMinutes - ChannelModerateAction.Slow -> data.slow?.waitTimeSeconds - else -> null - } + private fun parseDuration( + 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 + } - private fun parseReason(data: ModerationActionData): String? = when (data.moderationAction) { - ModerationActionType.Ban, - ModerationActionType.Delete, - -> data.args?.getOrNull(1) + private fun parseReason(data: ModerationActionData): String? = + when (data.moderationAction) { + ModerationActionType.Ban, + ModerationActionType.Delete, + -> data.args?.getOrNull(1) - ModerationActionType.Timeout -> data.args?.getOrNull(2) + ModerationActionType.Timeout -> data.args?.getOrNull(2) - else -> null - } + else -> null + } - private fun parseReason(data: ChannelModerateDto): String? = when (data.action) { - ChannelModerateAction.Ban -> data.ban?.reason + private fun parseReason(data: ChannelModerateDto): String? = + when (data.action) { + ChannelModerateAction.Ban -> data.ban?.reason - ChannelModerateAction.Delete -> data.delete?.messageBody + ChannelModerateAction.Delete -> data.delete?.messageBody - ChannelModerateAction.Timeout -> data.timeout?.reason + ChannelModerateAction.Timeout -> data.timeout?.reason - ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.reason + ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.reason - ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageBody + ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageBody - ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.reason + ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.reason - ChannelModerateAction.Warn -> data.warn?.let { listOfNotNull(it.reason).plus(it.chatRulesCited.orEmpty()).joinToString() } + 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\"" } + ChannelModerateAction.AddBlockedTerm, + ChannelModerateAction.AddPermittedTerm, + ChannelModerateAction.RemoveBlockedTerm, + ChannelModerateAction.RemovePermittedTerm, + -> data.automodTerms?.terms?.joinToString(" and ") { "\"$it\"" } - else -> null - } + else -> null + } - private fun parseTargetUser(data: ModerationActionData): UserName? = when (data.moderationAction) { - ModerationActionType.Delete -> data.args?.getOrNull(0)?.toUserName() - else -> data.targetUserName - } + private fun parseTargetUser(data: ModerationActionData): UserName? = + when (data.moderationAction) { + ModerationActionType.Delete -> data.args?.getOrNull(0)?.toUserName() + 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.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 - } + 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.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 + } - private fun parseTargetMsgId(data: ModerationActionData): String? = when (data.moderationAction) { - ModerationActionType.Delete -> data.args?.getOrNull(2) - else -> null - } + private fun parseTargetMsgId(data: ModerationActionData): String? = + when (data.moderationAction) { + ModerationActionType.Delete -> data.args?.getOrNull(2) + else -> null + } - private fun parseTargetMsgId(data: ChannelModerateDto): String? = when (data.action) { - ChannelModerateAction.Delete -> data.delete?.messageId - ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageId - else -> null - } + private fun parseTargetMsgId(data: ChannelModerateDto): String? = + when (data.action) { + ChannelModerateAction.Delete -> data.delete?.messageId + ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageId + else -> null + } - private fun ModerationActionType.toAction() = when (this) { - ModerationActionType.Timeout -> Action.Timeout - 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 - } + private fun ModerationActionType.toAction() = + when (this) { + ModerationActionType.Timeout -> Action.Timeout + 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 + } - 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 - ChannelModerateAction.AddBlockedTerm -> Action.AddBlockedTerm - ChannelModerateAction.AddPermittedTerm -> Action.AddPermittedTerm - ChannelModerateAction.RemoveBlockedTerm -> Action.RemoveBlockedTerm - ChannelModerateAction.RemovePermittedTerm -> Action.RemovePermittedTerm - else -> error("Unexpected moderation action $this") - } + 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 + 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 28b37e879..0bebf9096 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 @@ -14,35 +14,36 @@ data class NoticeMessage( 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] - } + 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] + } - else -> { - params[1] + else -> { + params[1] + } } - } - val ts = tags["rm-received-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val id = tags["id"] ?: UUID.randomUUID().toString() + val ts = tags["rm-received-ts"]?.toLongOrNull() ?: System.currentTimeMillis() + val id = tags["id"] ?: UUID.randomUUID().toString() - return NoticeMessage( - timestamp = ts, - id = id, - channel = channel.toUserName(), - message = notice, - ) - } + return NoticeMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + message = notice, + ) + } val ROOM_STATE_CHANGE_MSG_IDS = listOf( 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 01a9c09e5..e5ca4d58e 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 @@ -22,7 +22,10 @@ data class PointRedemptionMessage( val userDisplay: UserDisplay? = null, ) : 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(), @@ -31,8 +34,8 @@ data class PointRedemptionMessage( displayName = data.user.displayName, title = data.reward.title, rewardImageUrl = - data.reward.images?.imageLarge - ?: data.reward.defaultImages.imageLarge, + data.reward.images?.imageLarge + ?: data.reward.defaultImages.imageLarge, cost = data.reward.cost, 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 fd41f1569..1546befea 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 @@ -43,53 +43,57 @@ data class PrivMessage( override val badgeData: BadgeData = 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 displayName = tags["display-name"] ?: name + val color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) ?: DEFAULT_COLOR - 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 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) + } - 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 channel = params[0].substring(1).toUserName() + val sourceChannel = + tags["source-room-id"] + ?.takeIf { it.isNotEmpty() && it != tags["room-id"] } + ?.toUserId() + ?.let(findChannel) - return PrivMessage( - timestamp = ts, - channel = channel, - sourceChannel = sourceChannel, - name = name.toUserName(), - displayName = displayName.toDisplayName(), - color = color, - message = message, - isAction = isAction, - id = id, - userId = tags["user-id"]?.toUserId(), - timedOut = tags["rm-deleted"] == "1", - tags = tags, - ) - } + return PrivMessage( + timestamp = ts, + channel = channel, + sourceChannel = sourceChannel, + name = name.toUserName(), + displayName = displayName.toDisplayName(), + color = color, + message = message, + isAction = isAction, + id = id, + userId = tags["user-id"]?.toUserId(), + timedOut = tags["rm-deleted"] == "1", + tags = tags, + ) + } } } @@ -112,4 +116,6 @@ val PrivMessage.isElevatedMessage: Boolean val PrivMessage.aliasOrFormattedName: String get() = userDisplay?.alias ?: name.formatWithDisplayName(displayName) -fun PrivMessage.customOrUserColorOn(@ColorInt bgColor: Int): Int = userDisplay?.color ?: color.normalizeColor(bgColor) +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 67eceb46b..ee4bea261 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 @@ -31,59 +31,65 @@ data class RoomState( val followerModeDuration get() = tags[RoomStateTag.FOLLOW]?.takeIf { it >= 0 } val slowModeWaitTime get() = tags[RoomStateTag.SLOW]?.takeIf { it > 0 } - fun toDebugText(): String = tags - .filter { (it.key == RoomStateTag.FOLLOW && it.value >= 0) || it.value > 0 } - .map { (tag, value) -> - when (tag) { - RoomStateTag.FOLLOW -> { - when (value) { - 0 -> "follow" - else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" + fun toDebugText(): String = + tags + .filter { (it.key == RoomStateTag.FOLLOW && it.value >= 0) || it.value > 0 } + .map { (tag, value) -> + when (tag) { + RoomStateTag.FOLLOW -> { + when (value) { + 0 -> "follow" + else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" + } } - } - RoomStateTag.SLOW -> { - "slow(${DateTimeUtils.formatSeconds(value)})" - } + RoomStateTag.SLOW -> { + "slow(${DateTimeUtils.formatSeconds(value)})" + } - else -> { - tag.name.lowercase() + else -> { + tag.name.lowercase() + } } - } - }.joinToString() + }.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) - } + 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.SUBS -> { + TextResource.Res(R.string.room_state_subscriber_only) + } - RoomStateTag.R9K -> { - TextResource.Res(R.string.room_state_unique_chat) - } + 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.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))) + 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() + }.toImmutableList() - fun copyFromIrcMessage(msg: IrcMessage): RoomState = copy( - tags = tags.mapValues { (key, value) -> msg.getRoomStateTag(key, value) }, - ) + 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/SystemMessageType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessageType.kt index 317d777e0..7baa4b942 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 @@ -20,33 +20,63 @@ sealed interface 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: String, + ) : SystemMessageType data object SendNotLoggedIn : SystemMessageType - data class SendChannelNotResolved(val channel: UserName) : SystemMessageType + data class SendChannelNotResolved( + val channel: UserName, + ) : SystemMessageType data object SendNotDelivered : SystemMessageType - data class SendDropped(val reason: String, val code: String) : SystemMessageType + data class SendDropped( + val reason: String, + val code: String, + ) : SystemMessageType data object SendMissingScopes : SystemMessageType @@ -56,11 +86,18 @@ sealed interface SystemMessageType { data object SendRateLimited : SystemMessageType - data class SendFailed(val message: String?) : SystemMessageType + data class SendFailed( + val message: String?, + ) : SystemMessageType - data class Debug(val message: String) : SystemMessageType + data class Debug( + val message: String, + ) : SystemMessageType - data class AutomodActionFailed(val statusCode: Int?, val allow: Boolean) : SystemMessageType + data class AutomodActionFailed( + val statusCode: Int?, + val allow: Boolean, + ) : SystemMessageType } fun SystemMessageType.toChatItem() = ChatItem(SystemMessage(this), importance = ChatImportance.SYSTEM) 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 476966d5d..2a96e35ff 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 @@ -4,12 +4,18 @@ 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?) - -fun UserDisplayEntity.toUserDisplay() = UserDisplay( - alias = alias?.takeIf { enabled && aliasEnabled && it.isNotBlank() }, - color = color.takeIf { enabled && colorEnabled }, +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 +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 5be1c6728..4c467f7c9 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 @@ -29,66 +29,71 @@ data class UserNoticeMessage( "announcement", ) - 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"] - } else { - val roomId = tags["room-id"] - val sourceRoomId = tags["source-room-id"] - if (roomId != null && sourceRoomId != null) { - mirrored = roomId != sourceRoomId + 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"] + } else { + val roomId = tags["room-id"] + val sourceRoomId = tags["source-room-id"] + if (roomId != null && sourceRoomId != null) { + mirrored = roomId != sourceRoomId + } } - } - if (mirrored && msgId != "announcement") { - return null - } + if (mirrored && msgId != "announcement") { + return null + } - val id = tags["id"] ?: UUID.randomUUID().toString() - val channel = params[0].substring(1) - val defaultMessage = tags["system-msg"] ?: "" - val systemMsg = - when { - msgId == "announcement" -> { - "Announcement" - } + 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 + 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] - } + historic -> { + params[1] + } - else -> { - defaultMessage + else -> { + defaultMessage + } } - } - val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() + 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, - id = id, - channel = channel.toUserName(), - message = systemMsg, - childMessage = childMessage?.takeIf { it.message.isNotBlank() }, - tags = tags, - ) - } + return UserNoticeMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + message = systemMsg, + childMessage = childMessage?.takeIf { it.message.isNotBlank() }, + tags = tags, + ) + } } } 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 4ccdb1801..38c3e8700 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 @@ -42,75 +42,85 @@ data class WhisperMessage( companion object { val WHISPER_CHANNEL = "w".toUserName() - 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 message = params.getOrElse(1) { "" } + 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 message = params.getOrElse(1) { "" } - return WhisperMessage( - timestamp = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis(), - id = tags["id"] ?: UUID.randomUUID().toString(), - userId = tags["user-id"]?.toUserId(), - name = name.toUserName(), - displayName = displayName.toDisplayName(), - color = color, - recipientId = null, - recipientName = recipientName.toUserName(), - recipientDisplayName = recipientName, - recipientColor = recipientColor, - message = message, - rawEmotes = emoteTag, - rawBadges = tags["badges"], - rawBadgeInfo = tags["badge-info"], - ) - } + return WhisperMessage( + timestamp = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis(), + id = tags["id"] ?: UUID.randomUUID().toString(), + userId = tags["user-id"]?.toUserId(), + name = name.toUserName(), + displayName = displayName.toDisplayName(), + color = color, + recipientId = null, + recipientName = recipientName.toUserName(), + recipientDisplayName = recipientName, + recipientColor = recipientColor, + 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}" } - } + 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, - ) - } + 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, + ) + } } } val WhisperMessage.senderAliasOrFormattedName: String get() = userDisplay?.alias ?: name.formatWithDisplayName(displayName) -fun WhisperMessage.senderColorOnBackground(@ColorInt background: Int): Int = userDisplay.colorOrElse(color.normalizeColor(background)) +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)) +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 2b8a3f1ae..c78db059b 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 @@ -48,7 +48,13 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @OptIn(DelicateCoroutinesApi::class) -class PubSubConnection(val tag: String, private val client: HttpClient, private val scope: CoroutineScope, private val oAuth: String, private val jsonFormat: Json) { +class PubSubConnection( + val tag: String, + private val client: HttpClient, + private val scope: CoroutineScope, + private val oAuth: String, + private val jsonFormat: Json, +) { @Volatile private var session: DefaultClientWebSocketSession? = null private var connectionJob: Job? = null @@ -70,9 +76,10 @@ class PubSubConnection(val tag: String, private val client: HttpClient, private val hasWhisperTopic: Boolean get() = topics.any { it.topic.startsWith("whispers.") } - 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 || connectionJob?.isActive == true) { @@ -87,72 +94,76 @@ class PubSubConnection(val tag: String, private val client: HttpClient, private topics.addAll(possibleTopics) Log.i(TAG, "[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) - Log.i(TAG, "[PubSub $tag] connected") - - possibleTopics - .toRequestMessages() - .forEach { send(Frame.Text(it)) } - Log.d(TAG, "[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() - if (cause == null) return@webSocket - throw cause - } - - else -> (frame as? Frame.Text)?.readText() ?: continue + 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) + Log.i(TAG, "[PubSub $tag] connected") + + possibleTopics + .toRequestMessages() + .forEach { send(Frame.Text(it)) } + Log.d(TAG, "[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() + if (cause == null) return@webSocket + throw cause + } + + else -> { + (frame as? Frame.Text)?.readText() ?: continue + } + } + + serverRequestedReconnect = handleMessage(text) + if (serverRequestedReconnect) return@webSocket } - - serverRequestedReconnect = handleMessage(text) - if (serverRequestedReconnect) return@webSocket + } finally { + pingJob?.cancel() } - } finally { - pingJob?.cancel() } - } - session = null - receiveChannel.trySend(PubSubEvent.Closed) + session = null + receiveChannel.trySend(PubSubEvent.Closed) - if (!serverRequestedReconnect) { - Log.i(TAG, "[PubSub $tag] connection closed") - return@launch + if (!serverRequestedReconnect) { + Log.i(TAG, "[PubSub $tag] connection closed") + return@launch + } + Log.i(TAG, "[PubSub $tag] reconnecting after server request") + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + Log.e(TAG, "[PubSub $tag] connection failed: $t") + Log.e(TAG, "[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) } - Log.i(TAG, "[PubSub $tag] reconnecting after server request") - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - Log.e(TAG, "[PubSub $tag] connection failed: $t") - Log.e(TAG, "[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) } - } - Log.e(TAG, "[PubSub $tag] connection failed after $RECONNECT_MAX_ATTEMPTS retries") - session = null - } + Log.e(TAG, "[PubSub $tag] connection failed after $RECONNECT_MAX_ATTEMPTS retries") + session = null + } return remainingTopics.toSet() } @@ -180,9 +191,10 @@ class PubSubConnection(val tag: String, private val client: HttpClient, private } 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()) } @@ -227,19 +239,20 @@ class PubSubConnection(val tag: String, private val client: HttpClient, private private fun randomJitter() = Random.nextLong(range = 0L..MAX_JITTER).milliseconds - private fun setupPingInterval() = scope.timer(interval = PING_INTERVAL - randomJitter()) { - val currentSession = session - if (awaitingPong || currentSession?.isActive != true) { - cancel() - reconnect() - return@timer - } + private fun setupPingInterval() = + scope.timer(interval = PING_INTERVAL - randomJitter()) { + val currentSession = session + if (awaitingPong || currentSession?.isActive != true) { + cancel() + reconnect() + return@timer + } - if (connected) { - awaitingPong = true - runCatching { currentSession.send(Frame.Text(PING_PAYLOAD)) } + if (connected) { + awaitingPong = true + runCatching { currentSession.send(Frame.Text(PING_PAYLOAD)) } + } } - } /** * Handles a PubSub message. Returns true if the server requested a reconnect. @@ -249,7 +262,9 @@ class PubSubConnection(val tag: String, private val client: HttpClient, private val json = JSONObject(text) val type = json.optString("type").ifBlank { return false } when (type) { - "PONG" -> awaitingPong = false + "PONG" -> { + awaitingPong = false + } "RECONNECT" -> { Log.i(TAG, "[PubSub $tag] server requested reconnect") @@ -270,77 +285,83 @@ class PubSubConnection(val tag: String, private val client: HttpClient, private val messageObject = JSONObject(message) val messageTopic = messageObject.optString("type") val match = topics.find { topic == it.topic } ?: return false - val pubSubMessage = when (match) { - is PubSubTopic.Whispers -> { - if (messageTopic !in listOf("whisper_sent", "whisper_received")) { - return false + val pubSubMessage = + when (match) { + is PubSubTopic.Whispers -> { + if (messageTopic !in listOf("whisper_sent", "whisper_received")) { + return false + } + + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false + PubSubMessage.Whisper(parsedMessage.data) } - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false - PubSubMessage.Whisper(parsedMessage.data) - } + is PubSubTopic.PointRedemptions -> { + if (messageTopic != "reward-redeemed") { + return false + } - is PubSubTopic.PointRedemptions -> { - if (messageTopic != "reward-redeemed") { - return false + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false + PubSubMessage.PointRedemption( + timestamp = parsedMessage.data.timestamp, + channelName = match.channelName, + channelId = match.channelId, + data = parsedMessage.data.redemption, + ) } - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false - PubSubMessage.PointRedemption( - timestamp = parsedMessage.data.timestamp, - channelName = match.channelName, - channelId = match.channelId, - data = parsedMessage.data.redemption, - ) - } + is PubSubTopic.ModeratorActions -> { + when (messageTopic) { + "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, + ), + ) + } - is PubSubTopic.ModeratorActions -> { - when (messageTopic) { - "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, - ), - ) - } + "moderation_action" -> { + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false + if (parsedMessage.data.moderationAction == ModerationActionType.Mod) { + 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 }, + ), + ) + } - "moderation_action" -> { - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false - if (parsedMessage.data.moderationAction == ModerationActionType.Mod) { + else -> { 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 }, - ), - ) } - - else -> return false } } - } receiveChannel.trySend(PubSubEvent.Message(pubSubMessage)) } } @@ -357,20 +378,24 @@ class PubSubConnection(val tag: String, private val client: HttpClient, private ) } - 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() } 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 85f392f94..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,7 +1,9 @@ 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 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 86466c9dc..4538336fa 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 @@ -97,18 +97,23 @@ class PubSubManager( resetCollectionWith() } - 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)) + 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)) + } + } if (shouldUsePubSub) { - add(PubSubTopic.ModeratorActions(userId = uid, channelId = channel.id, channelName = channel.name)) + add(PubSubTopic.Whispers(uid)) } } - if (shouldUsePubSub) { - add(PubSubTopic.Whispers(uid)) - } - } private fun listen(topics: Set) { val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return @@ -149,18 +154,19 @@ class PubSubManager( 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() } - }, - ) - } + 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() } + }, + ) + } private suspend fun PubSubConnection.collectEvents() { events.collect { 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 9bcc55274..27d9ad114 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 @@ -8,9 +8,20 @@ import com.flxrs.dankchat.data.twitch.pubsub.dto.whisper.WhisperData import kotlin.time.Instant sealed interface PubSubMessage { - data class PointRedemption(val timestamp: Instant, val channelName: UserName, val channelId: UserId, val data: PointRedemptionData) : PubSubMessage + data class PointRedemption( + val timestamp: Instant, + val channelName: UserName, + val channelId: UserId, + val data: PointRedemptionData, + ) : PubSubMessage - data class Whisper(val data: WhisperData) : PubSubMessage + data class Whisper( + val data: WhisperData, + ) : PubSubMessage - data class ModeratorAction(val timestamp: Instant, val channelId: UserId, val data: ModerationActionData) : PubSubMessage + data class ModeratorAction( + val timestamp: Instant, + val channelId: UserId, + 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 48ca1f1eb..3356f3957 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,10 +3,21 @@ 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") +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 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") + 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 5e7364a30..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 @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PubSubDataMessage(val type: String, val data: T) +data class PubSubDataMessage( + val type: String, + 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 index bd439e848..956127bf2 100644 --- 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 @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PubSubDataObjectMessage(val type: String, @SerialName("data_object") val data: T) +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 5f7823cf7..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 @@ -6,4 +6,7 @@ import kotlin.time.Instant @Keep @Serializable -data class PointRedemption(val redemption: PointRedemptionData, val timestamp: Instant) +data class PointRedemption( + val redemption: PointRedemptionData, + val timestamp: Instant, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt index 08ac29f44..0a334f898 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionData.kt @@ -5,4 +5,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PointRedemptionData(val id: String, val user: PointRedemptionUser, val reward: PointRedemptionReward) +data class PointRedemptionData( + val id: String, + val user: PointRedemptionUser, + val reward: PointRedemptionReward, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt index a4b987ad2..8a59b4d83 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionImages.kt @@ -6,4 +6,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PointRedemptionImages(@SerialName("url_1x") val imageSmall: String, @SerialName("url_2x") val imageMedium: String, @SerialName("url_4x") val imageLarge: String) +data class PointRedemptionImages( + @SerialName("url_1x") val imageSmall: String, + @SerialName("url_2x") val imageMedium: String, + @SerialName("url_4x") val imageLarge: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt index 151b07d82..a2ee1af9c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionUser.kt @@ -9,4 +9,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PointRedemptionUser(val id: UserId, @SerialName("login") val name: UserName, @SerialName("display_name") val displayName: DisplayName) +data class PointRedemptionUser( + val id: UserId, + @SerialName("login") val name: UserName, + @SerialName("display_name") val displayName: DisplayName, +) 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 index 5cb5fd240..7be513d17 100644 --- 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 @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class WhisperDataBadge(val id: String, val version: String) +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 index c67513f20..74468a8da 100644 --- 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 @@ -6,4 +6,8 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class WhisperDataEmote(@SerialName("emote_id") val id: String, val start: Int, val end: Int) +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 index cd063e869..18e15a94b 100644 --- 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 @@ -9,4 +9,9 @@ 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) +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/di/ConnectionModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt index ea4358db6..a74c4bf04 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt @@ -16,11 +16,17 @@ data object WriteConnection class ConnectionModule { @Single @Named(type = ReadConnection::class) - fun provideReadConnection(httpClient: HttpClient, dispatchersProvider: DispatchersProvider, authDataStore: AuthDataStore): ChatConnection = - ChatConnection(ChatConnectionType.Read, httpClient, authDataStore, dispatchersProvider) + fun provideReadConnection( + httpClient: HttpClient, + dispatchersProvider: DispatchersProvider, + authDataStore: AuthDataStore, + ): ChatConnection = ChatConnection(ChatConnectionType.Read, httpClient, authDataStore, dispatchersProvider) @Single @Named(type = WriteConnection::class) - fun provideWriteConnection(httpClient: HttpClient, dispatchersProvider: DispatchersProvider, authDataStore: AuthDataStore): ChatConnection = - ChatConnection(ChatConnectionType.Write, httpClient, authDataStore, dispatchersProvider) + fun provideWriteConnection( + httpClient: HttpClient, + dispatchersProvider: 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..eda9d64fd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt @@ -8,12 +8,13 @@ import org.koin.core.annotation.Single @Module class CoroutineModule { @Single - fun provideDispatchersProvider(): DispatchersProvider = object : DispatchersProvider { - override val default: CoroutineDispatcher = Dispatchers.Default - override val io: CoroutineDispatcher = Dispatchers.IO - override val main: CoroutineDispatcher = Dispatchers.Main - override val immediate: CoroutineDispatcher = Dispatchers.Main.immediate - } + fun provideDispatchersProvider(): DispatchersProvider = + object : DispatchersProvider { + override val default: CoroutineDispatcher = Dispatchers.Default + override val io: CoroutineDispatcher = Dispatchers.IO + override val main: CoroutineDispatcher = Dispatchers.Main + override val immediate: CoroutineDispatcher = Dispatchers.Main.immediate + } } interface DispatchersProvider { 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 6d7fce886..67cbbd248 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt @@ -18,10 +18,11 @@ import org.koin.core.annotation.Single @Module class DatabaseModule { @Single - fun provideDatabase(context: Context): DankChatDatabase = Room - .databaseBuilder(context, DankChatDatabase::class.java, DB_NAME) - .addMigrations(DankChatDatabase.MIGRATION_4_5) - .build() + 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() 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 3ac822f06..ff2076f34 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -55,80 +55,92 @@ class NetworkModule { @Single @Named(type = WebSocketOkHttpClient::class) - fun provideOkHttpClient(): OkHttpClient = OkHttpClient - .Builder() - .callTimeout(20.seconds.toJavaDuration()) - .build() + fun provideOkHttpClient(): OkHttpClient = + OkHttpClient + .Builder() + .callTimeout(20.seconds.toJavaDuration()) + .build() @Single @Named(type = UploadOkHttpClient::class) - fun provideUploadOkHttpClient(): OkHttpClient = OkHttpClient - .Builder() - .callTimeout(60.seconds.toJavaDuration()) - .build() + fun provideUploadOkHttpClient(): OkHttpClient = + OkHttpClient + .Builder() + .callTimeout(60.seconds.toJavaDuration()) + .build() @Single - fun provideJson(): Json = Json { - explicitNulls = false - ignoreUnknownKeys = true - isLenient = true - coerceInputValues = true - } + fun provideJson(): Json = + Json { + explicitNulls = false + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } @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 = + HttpClient(OkHttp) { + install(Logging) { + level = LogLevel.INFO + logger = + object : Logger { + override fun log(message: String) { + Log.v("HttpClient", message) + } } - } - } - install(HttpCache) - install(UserAgent) { - agent = "dankchat/${BuildConfig.VERSION_NAME}" - } - install(ContentNegotiation) { - json(json) - } - install(HttpTimeout) { - connectTimeoutMillis = 15_000 - requestTimeoutMillis = 15_000 - socketTimeoutMillis = 15_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, authDataStore: AuthDataStore, helixApiStats: HelixApiStats, startupValidationHolder: StartupValidationHolder) = HelixApi( + fun provideHelixApi( + ktorClient: HttpClient, + authDataStore: AuthDataStore, + helixApiStats: HelixApiStats, + startupValidationHolder: StartupValidationHolder, + ) = HelixApi( ktorClient.config { defaultRequest { url(HELIX_BASE_URL) @@ -145,34 +157,40 @@ class NetworkModule { ) @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( + fun provideRecentMessagesApi( + ktorClient: HttpClient, + developerSettingsDataStore: DeveloperSettingsDataStore, + ) = RecentMessagesApi( ktorClient.config { defaultRequest { url(developerSettingsDataStore.current().customRecentMessagesHost) @@ -181,11 +199,12 @@ class NetworkModule { ) @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 index 59194d3f7..c2e14bc6e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -83,9 +83,10 @@ class ChannelDataCoordinator( } } - fun getChannelLoadingState(channel: UserName): StateFlow = channelStates.getOrPut(channel) { - MutableStateFlow(ChannelLoadingState.Idle) - } + fun getChannelLoadingState(channel: UserName): StateFlow = + channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) + } /** * Load data when a channel is added diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index e29738499..b0be9d20f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -74,45 +74,53 @@ class ChannelDataLoader( } } - 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(), + 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/GetChannelsUseCase.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt index bb8d30ee6..a81279331 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt @@ -13,39 +13,42 @@ import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Single @Single -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 +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 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 - channelRepository.cacheChannels(roomStateChannels) + val roomStateChannels = + roomStatePairs.map { + Channel(id = it.channelId, name = it.channel, displayName = it.channel.toDisplayName(), avatarUrl = null) + } + remainingPairs + channelRepository.cacheChannels(roomStateChannels) - channels + roomStateChannels - } + channels + roomStateChannels + } companion object { private const val IRC_TIMEOUT_DELAY = 5_000L diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 4d5f952ef..5925f8cda 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -19,26 +19,28 @@ class GlobalDataLoader( 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 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() @@ -54,5 +56,8 @@ class GlobalDataLoader( suspend fun loadUserBlocks() = ignoresRepository.loadUserBlocks() - suspend fun loadUserEmotes(userId: UserId, onFirstPageLoaded: (() -> Unit)? = null): Result = dataRepository.loadUserEmotes(userId, onFirstPageLoaded) + suspend fun loadUserEmotes( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ): Result = dataRepository.loadUserEmotes(userId, onFirstPageLoaded) } 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 95b6998a8..275163790 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -26,7 +26,12 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class DankChatPreferenceStore(private val context: Context, private val json: Json, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val authDataStore: AuthDataStore) { +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) private var channelRenames: String? @@ -102,10 +107,15 @@ class DankChatPreferenceStore(private val context: Context, private val json: Js .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 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 clearLogin() { authDataStore.updateAsync { it.copy(oAuthKey = null, userName = null, displayName = null, userId = null, clientId = AuthSettings.DEFAULT_CLIENT_ID, isLoggedIn = false) } @@ -130,19 +140,20 @@ class DankChatPreferenceStore(private val context: Context, private val json: Js } } - fun getChannelsWithRenamesFlow(): Flow> = callbackFlow { - send(getChannelsWithRenames()) + 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) } - } + dankChatPreferences.registerOnSharedPreferenceChangeListener(listener) + awaitClose { dankChatPreferences.unregisterOnSharedPreferenceChangeListener(listener) } + } fun setRenamedChannel(channelWithRename: ChannelWithRename) { withChannelRenames { @@ -181,10 +192,11 @@ class DankChatPreferenceStore(private val context: Context, private val json: Js channelRenames = renameMap.toJson() } - private fun String.toMutableMap(): MutableMap = json - .decodeOrNull>(this) - .orEmpty() - .toMutableMap() + private fun String.toMutableMap(): MutableMap = + json + .decodeOrNull>(this) + .orEmpty() + .toMutableMap() private fun Map.toJson(): String = json.encodeToString(this) 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 index 6ba5f39f8..cbd5d0015 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -49,9 +49,10 @@ fun AboutScreen(onBack: () -> 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, @@ -66,35 +67,40 @@ fun AboutScreen(onBack: () -> Unit) { }, ) { padding -> val context = LocalContext.current - val libraries = produceState(null) { - value = withContext(Dispatchers.IO) { - Libs.Builder().withContext(context).build() + 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), + 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) - } - } + 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 }, 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 05267efc6..75ca1815f 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 @@ -21,8 +21,13 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class AppearanceSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { - private enum class AppearancePreferenceKeys(override val id: Int) : PreferenceKeys { +class AppearanceSettingsDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + 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), @@ -41,11 +46,11 @@ class AppearanceSettingsDataStore(context: Context, dispatchersProvider: Dispatc AppearancePreferenceKeys.Theme -> { acc.copy( theme = - value.mappedStringOrDefault( - original = context.resources.getStringArray(R.array.theme_entry_values), - enumEntries = ThemePreference.entries, - default = acc.theme, - ), + value.mappedStringOrDefault( + original = context.resources.getStringArray(R.array.theme_entry_values), + enumEntries = ThemePreference.entries, + default = acc.theme, + ), ) } 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 index 6e9cf66ba..758f5c9ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -97,10 +97,11 @@ private fun AppearanceSettingsContent( }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { ThemeCategory( theme = settings.theme, @@ -127,7 +128,11 @@ private fun AppearanceSettingsContent( } @Composable -private fun ComponentsCategory(autoDisableInput: Boolean, showCharacterCounter: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit) { +private fun ComponentsCategory( + autoDisableInput: Boolean, + showCharacterCounter: Boolean, + onInteraction: (AppearanceSettingsInteraction) -> Unit, +) { PreferenceCategory( title = stringResource(R.string.preference_components_group_title), ) { @@ -146,7 +151,13 @@ private fun ComponentsCategory(autoDisableInput: Boolean, showCharacterCounter: } @Composable -private fun DisplayCategory(fontSize: Int, keepScreenOn: Boolean, lineSeparator: Boolean, checkeredMessages: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit) { +private fun DisplayCategory( + fontSize: Int, + keepScreenOn: Boolean, + lineSeparator: Boolean, + checkeredMessages: Boolean, + onInteraction: (AppearanceSettingsInteraction) -> Unit, +) { PreferenceCategory( title = stringResource(R.string.preference_display_group_title), ) { @@ -182,7 +193,11 @@ private fun DisplayCategory(fontSize: Int, keepScreenOn: Boolean, lineSeparator: } @Composable -private fun ThemeCategory(theme: ThemePreference, trueDarkTheme: Boolean, onInteraction: suspend (AppearanceSettingsInteraction) -> Unit) { +private fun ThemeCategory( + theme: ThemePreference, + trueDarkTheme: Boolean, + onInteraction: suspend (AppearanceSettingsInteraction) -> Unit, +) { val scope = rememberCoroutineScope() val themeState = rememberThemeState(theme, trueDarkTheme, isSystemInDarkTheme()) PreferenceCategory( @@ -233,16 +248,21 @@ data class ThemeState( @Composable @Stable -private fun rememberThemeState(theme: ThemePreference, trueDark: Boolean, systemDarkMode: Boolean): ThemeState { +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() - } + val (entries, values) = + remember { + defaultEntries to ThemePreference.entries.toImmutableList() + } return remember(theme, trueDark) { val selected = if (theme in values) theme else ThemePreference.Dark @@ -259,14 +279,21 @@ private fun rememberThemeState(theme: ThemePreference, trueDark: Boolean, system } } -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) -} +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) + } -private fun setDarkMode(themePreference: ThemePreference, activity: Activity) { +private fun setDarkMode( + themePreference: ThemePreference, + activity: Activity, +) { AppCompatDelegate.setDefaultNightMode( when (themePreference) { ThemePreference.System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM 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 index 10e156777..7619c11ca 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -3,24 +3,44 @@ package com.flxrs.dankchat.preferences.appearance import androidx.compose.runtime.Immutable sealed interface AppearanceSettingsInteraction { - data class Theme(val theme: ThemePreference) : AppearanceSettingsInteraction + data class Theme( + val theme: ThemePreference, + ) : AppearanceSettingsInteraction - data class TrueDarkTheme(val trueDarkTheme: Boolean) : AppearanceSettingsInteraction + data class TrueDarkTheme( + val trueDarkTheme: Boolean, + ) : AppearanceSettingsInteraction - data class FontSize(val fontSize: Int) : AppearanceSettingsInteraction + data class FontSize( + val fontSize: Int, + ) : AppearanceSettingsInteraction - data class KeepScreenOn(val value: Boolean) : AppearanceSettingsInteraction + data class KeepScreenOn( + val value: Boolean, + ) : AppearanceSettingsInteraction - data class LineSeparator(val value: Boolean) : AppearanceSettingsInteraction + data class LineSeparator( + val value: Boolean, + ) : AppearanceSettingsInteraction - data class CheckeredMessages(val value: Boolean) : AppearanceSettingsInteraction + data class CheckeredMessages( + val value: Boolean, + ) : AppearanceSettingsInteraction - data class AutoDisableInput(val value: Boolean) : AppearanceSettingsInteraction + data class AutoDisableInput( + val value: Boolean, + ) : AppearanceSettingsInteraction - data class ShowChangelogs(val value: Boolean) : AppearanceSettingsInteraction + data class ShowChangelogs( + val value: Boolean, + ) : AppearanceSettingsInteraction - data class ShowCharacterCounter(val value: Boolean) : AppearanceSettingsInteraction + data class ShowCharacterCounter( + val value: Boolean, + ) : AppearanceSettingsInteraction } @Immutable -data class AppearanceSettingsUiState(val settings: AppearanceSettings) +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 ff4290bc3..3a732e34e 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 @@ -11,7 +11,9 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class AppearanceSettingsViewModel(private val dataStore: AppearanceSettingsDataStore) : ViewModel() { +class AppearanceSettingsViewModel( + private val dataStore: AppearanceSettingsDataStore, +) : ViewModel() { val settings = dataStore.settings .map { AppearanceSettingsUiState(settings = it) } 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 8fbcd606d..4860ae4c7 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 @@ -40,7 +40,11 @@ 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(), +) enum class UserLongClickBehavior { MentionsUser, 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 1a93cd5c5..e2c2c1cac 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 @@ -30,8 +30,13 @@ import org.koin.core.annotation.Single import kotlin.time.Duration.Companion.seconds @Single -class ChatSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { - private enum class ChatPreferenceKeys(override val id: Int) : PreferenceKeys { +class ChatSettingsDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + private enum class ChatPreferenceKeys( + override val id: Int, + ) : PreferenceKeys { Suggestions(R.string.preference_suggestions_key), SupibotSuggestions(R.string.preference_supibot_suggestions_key), CustomCommands(R.string.preference_commands_key), @@ -86,9 +91,9 @@ class ChatSettingsDataStore(context: Context, dispatchersProvider: DispatchersPr ChatPreferenceKeys.UserLongClickBehavior -> { acc.copy( userLongClickBehavior = - value.booleanOrNull()?.let { - if (it) UserLongClickBehavior.MentionsUser else UserLongClickBehavior.OpensPopup - } ?: acc.userLongClickBehavior, + value.booleanOrNull()?.let { + if (it) UserLongClickBehavior.MentionsUser else UserLongClickBehavior.OpensPopup + } ?: acc.userLongClickBehavior, ) } @@ -107,13 +112,13 @@ class ChatSettingsDataStore(context: Context, dispatchersProvider: DispatchersPr 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(), + value + .mappedStringSetOrDefault( + original = context.resources.getStringArray(R.array.badges_entry_values), + enumEntries = VisibleBadges.entries, + default = acc.visibleBadges, + ).plus(VisibleBadges.SharedChat) + .distinct(), sharedChatMigration = true, ) } @@ -121,11 +126,11 @@ class ChatSettingsDataStore(context: Context, dispatchersProvider: DispatchersPr ChatPreferenceKeys.VisibleEmotes -> { acc.copy( visibleEmotes = - value.mappedStringSetOrDefault( - original = context.resources.getStringArray(R.array.emotes_entry_values), - enumEntries = VisibleThirdPartyEmotes.entries, - default = acc.visibleEmotes, - ), + value.mappedStringSetOrDefault( + original = context.resources.getStringArray(R.array.emotes_entry_values), + enumEntries = VisibleThirdPartyEmotes.entries, + default = acc.visibleEmotes, + ), ) } @@ -140,11 +145,11 @@ class ChatSettingsDataStore(context: Context, dispatchersProvider: DispatchersPr ChatPreferenceKeys.LiveUpdatesTimeout -> { acc.copy( sevenTVLiveEmoteUpdatesBehavior = - value.mappedStringOrDefault( - original = context.resources.getStringArray(R.array.event_api_timeout_entry_values), - enumEntries = LiveUpdatesBackgroundBehavior.entries, - default = acc.sevenTVLiveEmoteUpdatesBehavior, - ), + value.mappedStringOrDefault( + original = context.resources.getStringArray(R.array.event_api_timeout_entry_values), + enumEntries = LiveUpdatesBackgroundBehavior.entries, + default = acc.sevenTVLiveEmoteUpdatesBehavior, + ), ) } @@ -174,10 +179,11 @@ class ChatSettingsDataStore(context: Context, dispatchersProvider: DispatchersPr 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, - ) + override suspend fun migrate(currentData: ChatSettings): ChatSettings = + currentData.copy( + visibleBadges = currentData.visibleBadges.plus(VisibleBadges.SharedChat).distinct(), + sharedChatMigration = true, + ) override suspend fun cleanUp() = Unit } 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 38f7e5590..f6b2c1c0b 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 @@ -54,7 +54,11 @@ import org.koin.compose.viewmodel.koinViewModel import kotlin.math.roundToInt @Composable -fun ChatSettingsScreen(onNavToCommands: () -> Unit, onNavToUserDisplays: () -> Unit, onNavBack: () -> Unit) { +fun ChatSettingsScreen( + onNavToCommands: () -> Unit, + onNavToUserDisplays: () -> Unit, + onNavBack: () -> Unit, +) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value val restartRequiredTitle = stringResource(R.string.restart_required) @@ -66,11 +70,12 @@ fun ChatSettingsScreen(onNavToCommands: () -> Unit, onNavToUserDisplays: () -> U viewModel.events.collectLatest { when (it) { ChatSettingsEvent.RestartRequired -> { - val result = snackbarHostState.showSnackbar( - message = restartRequiredTitle, - actionLabel = restartRequiredAction, - duration = SnackbarDuration.Long, - ) + val result = + snackbarHostState.showSnackbar( + message = restartRequiredTitle, + actionLabel = restartRequiredAction, + duration = SnackbarDuration.Long, + ) if (result == SnackbarResult.ActionPerformed) { ProcessPhoenix.triggerRebirth(context) } @@ -117,10 +122,11 @@ private fun ChatSettingsScreen( }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { GeneralCategory( suggestions = settings.suggestions, @@ -261,9 +267,10 @@ private fun GeneralCategory( onChange = { onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, ) - val entries = stringArrayResource(R.array.badges_entries) - .plus(stringResource(R.string.shared_chat)) - .toImmutableList() + val entries = + stringArrayResource(R.array.badges_entries) + .plus(stringResource(R.string.shared_chat)) + .toImmutableList() PreferenceMultiListDialog( title = stringResource(R.string.preference_visible_badges_title), @@ -305,11 +312,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), @@ -323,7 +331,12 @@ private fun SevenTVCategory( } @Composable -private fun MessageHistoryCategory(loadMessageHistory: Boolean, loadMessageHistoryAfterReconnect: Boolean, messageHistoryDashboardUrl: String, onInteraction: (ChatSettingsInteraction) -> Unit) { +private fun MessageHistoryCategory( + loadMessageHistory: Boolean, + loadMessageHistoryAfterReconnect: Boolean, + messageHistoryDashboardUrl: String, + onInteraction: (ChatSettingsInteraction) -> Unit, +) { val launcher = LocalUriHandler.current PreferenceCategory(title = stringResource(R.string.preference_message_history_header)) { SwitchPreferenceItem( @@ -346,7 +359,10 @@ private fun MessageHistoryCategory(loadMessageHistory: Boolean, loadMessageHisto } @Composable -private fun ChannelDataCategory(showChatModes: Boolean, onInteraction: (ChatSettingsInteraction) -> Unit) { +private fun ChannelDataCategory( + showChatModes: Boolean, + onInteraction: (ChatSettingsInteraction) -> Unit, +) { PreferenceCategory(title = stringResource(R.string.preference_channel_data_header)) { SwitchPreferenceItem( title = stringResource(R.string.preference_roomstate_title), 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 index 75a3d1b64..1ce391083 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt @@ -8,41 +8,77 @@ sealed interface ChatSettingsEvent { } sealed interface ChatSettingsInteraction { - data class Suggestions(val value: Boolean) : ChatSettingsInteraction + data class Suggestions( + val value: Boolean, + ) : ChatSettingsInteraction - data class SupibotSuggestions(val value: Boolean) : ChatSettingsInteraction + data class SupibotSuggestions( + val value: Boolean, + ) : ChatSettingsInteraction - data class CustomCommands(val value: List) : ChatSettingsInteraction + data class CustomCommands( + val value: List, + ) : ChatSettingsInteraction - data class AnimateGifs(val value: Boolean) : ChatSettingsInteraction + data class AnimateGifs( + val value: Boolean, + ) : ChatSettingsInteraction - data class ScrollbackLength(val value: Int) : ChatSettingsInteraction + data class ScrollbackLength( + val value: Int, + ) : ChatSettingsInteraction - data class ShowUsernames(val value: Boolean) : ChatSettingsInteraction + data class ShowUsernames( + val value: Boolean, + ) : ChatSettingsInteraction - data class UserLongClick(val value: UserLongClickBehavior) : ChatSettingsInteraction + data class UserLongClick( + val value: UserLongClickBehavior, + ) : ChatSettingsInteraction - data class ShowTimedOutMessages(val value: Boolean) : ChatSettingsInteraction + data class ShowTimedOutMessages( + val value: Boolean, + ) : ChatSettingsInteraction - data class ShowTimestamps(val value: Boolean) : ChatSettingsInteraction + data class ShowTimestamps( + val value: Boolean, + ) : ChatSettingsInteraction - data class TimestampFormat(val value: String) : ChatSettingsInteraction + data class TimestampFormat( + val value: String, + ) : ChatSettingsInteraction - data class Badges(val value: List) : ChatSettingsInteraction + data class Badges( + val value: List, + ) : ChatSettingsInteraction - data class Emotes(val value: List) : ChatSettingsInteraction + data class Emotes( + val value: List, + ) : ChatSettingsInteraction - data class AllowUnlisted(val value: Boolean) : ChatSettingsInteraction + data class AllowUnlisted( + val value: Boolean, + ) : ChatSettingsInteraction - data class LiveEmoteUpdates(val value: Boolean) : ChatSettingsInteraction + data class LiveEmoteUpdates( + val value: Boolean, + ) : ChatSettingsInteraction - data class LiveEmoteUpdatesBehavior(val value: LiveUpdatesBackgroundBehavior) : ChatSettingsInteraction + data class LiveEmoteUpdatesBehavior( + val value: LiveUpdatesBackgroundBehavior, + ) : ChatSettingsInteraction - data class MessageHistory(val value: Boolean) : ChatSettingsInteraction + data class MessageHistory( + val value: Boolean, + ) : ChatSettingsInteraction - data class MessageHistoryAfterReconnect(val value: Boolean) : ChatSettingsInteraction + data class MessageHistoryAfterReconnect( + val value: Boolean, + ) : ChatSettingsInteraction - data class ChatModes(val value: Boolean) : ChatSettingsInteraction + data class ChatModes( + val value: Boolean, + ) : ChatSettingsInteraction } @Immutable 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 1297dfb8b..5eed1280d 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 @@ -14,7 +14,9 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class ChatSettingsViewModel(private val chatSettingsDataStore: ChatSettingsDataStore) : ViewModel() { +class ChatSettingsViewModel( + private val chatSettingsDataStore: ChatSettingsDataStore, +) : ViewModel() { private val _events = MutableSharedFlow() val events = _events.asSharedFlow() @@ -28,111 +30,113 @@ class ChatSettingsViewModel(private val chatSettingsDataStore: ChatSettingsDataS initialValue = initial.toState(), ) - fun onInteraction(interaction: ChatSettingsInteraction) = viewModelScope.launch { - runCatching { - when (interaction) { - is ChatSettingsInteraction.Suggestions -> { - chatSettingsDataStore.update { it.copy(suggestions = interaction.value) } - } + fun onInteraction(interaction: ChatSettingsInteraction) = + viewModelScope.launch { + runCatching { + when (interaction) { + is ChatSettingsInteraction.Suggestions -> { + chatSettingsDataStore.update { it.copy(suggestions = interaction.value) } + } - is ChatSettingsInteraction.SupibotSuggestions -> { - chatSettingsDataStore.update { it.copy(supibotSuggestions = interaction.value) } - } + is ChatSettingsInteraction.SupibotSuggestions -> { + chatSettingsDataStore.update { it.copy(supibotSuggestions = interaction.value) } + } - is ChatSettingsInteraction.CustomCommands -> { - chatSettingsDataStore.update { it.copy(customCommands = interaction.value) } - } + is ChatSettingsInteraction.CustomCommands -> { + chatSettingsDataStore.update { it.copy(customCommands = interaction.value) } + } - is ChatSettingsInteraction.AnimateGifs -> { - chatSettingsDataStore.update { it.copy(animateGifs = interaction.value) } - } + is ChatSettingsInteraction.AnimateGifs -> { + chatSettingsDataStore.update { it.copy(animateGifs = interaction.value) } + } - is ChatSettingsInteraction.ScrollbackLength -> { - chatSettingsDataStore.update { it.copy(scrollbackLength = interaction.value) } - } + is ChatSettingsInteraction.ScrollbackLength -> { + chatSettingsDataStore.update { it.copy(scrollbackLength = interaction.value) } + } - is ChatSettingsInteraction.ShowUsernames -> { - chatSettingsDataStore.update { it.copy(showUsernames = interaction.value) } - } + is ChatSettingsInteraction.ShowUsernames -> { + chatSettingsDataStore.update { it.copy(showUsernames = interaction.value) } + } - is ChatSettingsInteraction.UserLongClick -> { - chatSettingsDataStore.update { it.copy(userLongClickBehavior = interaction.value) } - } + is ChatSettingsInteraction.UserLongClick -> { + chatSettingsDataStore.update { it.copy(userLongClickBehavior = interaction.value) } + } - is ChatSettingsInteraction.ShowTimedOutMessages -> { - chatSettingsDataStore.update { it.copy(showTimedOutMessages = interaction.value) } - } + is ChatSettingsInteraction.ShowTimedOutMessages -> { + chatSettingsDataStore.update { it.copy(showTimedOutMessages = interaction.value) } + } - is ChatSettingsInteraction.ShowTimestamps -> { - chatSettingsDataStore.update { it.copy(showTimestamps = interaction.value) } - } + is ChatSettingsInteraction.ShowTimestamps -> { + chatSettingsDataStore.update { it.copy(showTimestamps = interaction.value) } + } - is ChatSettingsInteraction.TimestampFormat -> { - chatSettingsDataStore.update { it.copy(timestampFormat = interaction.value) } - } + is ChatSettingsInteraction.TimestampFormat -> { + chatSettingsDataStore.update { it.copy(timestampFormat = interaction.value) } + } - is ChatSettingsInteraction.Badges -> { - chatSettingsDataStore.update { it.copy(visibleBadges = 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.Emotes -> { + chatSettingsDataStore.update { it.copy(visibleEmotes = interaction.value) } + if (initial.visibleEmotes != interaction.value) { + _events.emit(ChatSettingsEvent.RestartRequired) + } } - } - is ChatSettingsInteraction.AllowUnlisted -> { - chatSettingsDataStore.update { it.copy(allowUnlistedSevenTvEmotes = interaction.value) } - if (initial.allowUnlistedSevenTvEmotes != interaction.value) { - _events.emit(ChatSettingsEvent.RestartRequired) + 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.LiveEmoteUpdates -> { + chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdates = interaction.value) } + } - is ChatSettingsInteraction.LiveEmoteUpdatesBehavior -> { - chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdatesBehavior = interaction.value) } - } + is ChatSettingsInteraction.LiveEmoteUpdatesBehavior -> { + chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdatesBehavior = interaction.value) } + } - is ChatSettingsInteraction.MessageHistory -> { - chatSettingsDataStore.update { it.copy(loadMessageHistory = interaction.value) } - } + is ChatSettingsInteraction.MessageHistory -> { + chatSettingsDataStore.update { it.copy(loadMessageHistory = interaction.value) } + } - is ChatSettingsInteraction.MessageHistoryAfterReconnect -> { - chatSettingsDataStore.update { it.copy(loadMessageHistoryOnReconnect = interaction.value) } - } + is ChatSettingsInteraction.MessageHistoryAfterReconnect -> { + chatSettingsDataStore.update { it.copy(loadMessageHistoryOnReconnect = interaction.value) } + } - is ChatSettingsInteraction.ChatModes -> { - chatSettingsDataStore.update { it.copy(showChatModes = interaction.value) } + is ChatSettingsInteraction.ChatModes -> { + chatSettingsDataStore.update { it.copy(showChatModes = interaction.value) } + } } } } - } } -private fun ChatSettings.toState() = ChatSettingsState( - suggestions = suggestions, - supibotSuggestions = supibotSuggestions, - customCommands = customCommands.toImmutableList(), - animateGifs = animateGifs, - scrollbackLength = scrollbackLength, - showUsernames = showUsernames, - userLongClickBehavior = userLongClickBehavior, - showTimedOutMessages = showTimedOutMessages, - showTimestamps = showTimestamps, - timestampFormat = timestampFormat, - visibleBadges = visibleBadges.toImmutableList(), - visibleEmotes = visibleEmotes.toImmutableList(), - allowUnlistedSevenTvEmotes = allowUnlistedSevenTvEmotes, - sevenTVLiveEmoteUpdates = sevenTVLiveEmoteUpdates, - sevenTVLiveEmoteUpdatesBehavior = sevenTVLiveEmoteUpdatesBehavior, - loadMessageHistory = loadMessageHistory, - loadMessageHistoryAfterReconnect = loadMessageHistoryOnReconnect, - messageHistoryDashboardUrl = RECENT_MESSAGES_DASHBOARD, - showChatModes = showChatModes, -) +private fun ChatSettings.toState() = + ChatSettingsState( + suggestions = suggestions, + supibotSuggestions = supibotSuggestions, + customCommands = customCommands.toImmutableList(), + animateGifs = animateGifs, + scrollbackLength = scrollbackLength, + showUsernames = showUsernames, + userLongClickBehavior = userLongClickBehavior, + showTimedOutMessages = showTimedOutMessages, + showTimestamps = showTimestamps, + timestampFormat = timestampFormat, + visibleBadges = visibleBadges.toImmutableList(), + visibleEmotes = visibleEmotes.toImmutableList(), + allowUnlistedSevenTvEmotes = allowUnlistedSevenTvEmotes, + sevenTVLiveEmoteUpdates = sevenTVLiveEmoteUpdates, + sevenTVLiveEmoteUpdatesBehavior = sevenTVLiveEmoteUpdatesBehavior, + loadMessageHistory = loadMessageHistory, + loadMessageHistoryAfterReconnect = loadMessageHistoryOnReconnect, + messageHistoryDashboardUrl = RECENT_MESSAGES_DASHBOARD, + showChatModes = showChatModes, + ) private const val RECENT_MESSAGES_DASHBOARD = "https://recent-messages.robotty.de" 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 b343d0b16..3261e0b51 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 @@ -73,7 +73,11 @@ fun CustomCommandsScreen(onNavBack: () -> Unit) { } @Composable -private fun CustomCommandsScreen(initialCommands: ImmutableList, onSaveAndNavBack: (List) -> Unit, onSave: (List) -> Unit) { +private fun CustomCommandsScreen( + initialCommands: ImmutableList, + onSaveAndNavBack: (List) -> Unit, + onSave: (List) -> Unit, +) { val focusManager = LocalFocusManager.current val commands = remember { initialCommands.toMutableStateList() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -91,9 +95,10 @@ private fun CustomCommandsScreen(initialCommands: ImmutableList, 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,9 +117,10 @@ private fun CustomCommandsScreen(initialCommands: ImmutableList, 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 = "") @@ -133,10 +139,11 @@ private fun CustomCommandsScreen(initialCommands: ImmutableList, 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 = { _, cmd -> cmd.id }) { idx, command -> CustomCommandItem( @@ -149,11 +156,12 @@ private fun CustomCommandsScreen(initialCommands: ImmutableList, val removed = commands.removeAt(idx) scope.launch { snackbarHost.currentSnackbarData?.dismiss() - val result = snackbarHost.showSnackbar( - message = itemRemovedMsg, - actionLabel = undoMsg, - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { focusManager.clearFocus() commands.add(idx, removed) @@ -161,12 +169,13 @@ private fun CustomCommandsScreen(initialCommands: ImmutableList, } } }, - 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,14 +186,22 @@ private fun CustomCommandsScreen(initialCommands: ImmutableList, } @Composable -private fun CustomCommandItem(trigger: String, command: String, onTriggerChange: (String) -> Unit, onCommandChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun CustomCommandItem( + trigger: String, + command: String, + onTriggerChange: (String) -> Unit, + onCommandChange: (String) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { SwipeToDelete(onRemove, modifier) { ElevatedCard { Row { Column( - modifier = Modifier - .weight(1f) - .padding(16.dp), + modifier = + Modifier + .weight(1f) + .padding(16.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), 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 9bcb1055a..80d6120bc 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 @@ -14,7 +14,9 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class CommandsViewModel(private val chatSettingsDataStore: ChatSettingsDataStore) : ViewModel() { +class CommandsViewModel( + private val chatSettingsDataStore: ChatSettingsDataStore, +) : ViewModel() { val commands = chatSettingsDataStore.settings .map { it.customCommands.toImmutableList() } @@ -24,8 +26,9 @@ class CommandsViewModel(private val chatSettingsDataStore: ChatSettingsDataStore initialValue = chatSettingsDataStore.current().customCommands.toImmutableList(), ) - fun save(commands: List) = viewModelScope.launch { - val filtered = commands.filter { it.trigger.isNotBlank() && it.command.isNotBlank() } - chatSettingsDataStore.update { it.copy(customCommands = filtered) } - } + fun save(commands: List) = + viewModelScope.launch { + val filtered = commands.filter { it.trigger.isNotBlank() && it.command.isNotBlank() } + 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 09d6cacb7..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 @@ -6,10 +6,18 @@ import kotlinx.coroutines.flow.Flow @Immutable sealed interface UserDisplayEvent { - data class ItemRemoved(val item: UserDisplayItem, val position: Int) : UserDisplayEvent + data class ItemRemoved( + val item: UserDisplayItem, + val position: Int, + ) : UserDisplayEvent - data class ItemAdded(val position: Int, val isLast: Boolean) : 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 79123c98a..dd26e5c5f 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 @@ -13,25 +13,27 @@ data class UserDisplayItem( val alias: String, ) -fun UserDisplayItem.toEntity() = UserDisplayEntity( - id = id, - // prevent whitespace before/after name from messing up with matching - targetUser = username.trim(), - enabled = enabled, - colorEnabled = colorEnabled, - color = color, - aliasEnabled = aliasEnabled, - alias = alias.ifEmpty { null }, -) +fun UserDisplayItem.toEntity() = + UserDisplayEntity( + id = id, + // prevent whitespace before/after name from messing up with matching + targetUser = username.trim(), + enabled = enabled, + colorEnabled = colorEnabled, + color = color, + aliasEnabled = aliasEnabled, + alias = alias.ifEmpty { null }, + ) -fun UserDisplayEntity.toItem() = UserDisplayItem( - id = id, - username = targetUser, - enabled = enabled, - colorEnabled = colorEnabled, - color = color, - aliasEnabled = aliasEnabled, - alias = alias.orEmpty(), -) +fun UserDisplayEntity.toItem() = + UserDisplayItem( + id = id, + username = targetUser, + enabled = enabled, + colorEnabled = colorEnabled, + color = color, + aliasEnabled = aliasEnabled, + 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 ab062b05b..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 @@ -116,11 +116,12 @@ private fun UserDisplayScreen( focusManager.clearFocus() when (it) { is UserDisplayEvent.ItemRemoved -> { - val result = snackbarHost.showSnackbar( - message = itemRemovedMsg, - actionLabel = undoMsg, - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { onAdd(it.item, 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( @@ -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 = { _, 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") { @@ -208,14 +213,20 @@ private fun UserDisplayScreen( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun UserDisplayItem(item: UserDisplayItem, onChange: (UserDisplayItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun UserDisplayItem( + item: UserDisplayItem, + onChange: (UserDisplayItem) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { SwipeToDelete(onRemove, modifier) { ElevatedCard { Row { Column( - modifier = Modifier - .weight(1f) - .padding(16.dp), + modifier = + Modifier + .weight(1f) + .padding(16.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), @@ -279,16 +290,18 @@ private fun UserDisplayItem(item: UserDisplayItem, onChange: (UserDisplayItem) - 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 -> 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 f16863f44..60dea62fc 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 @@ -13,7 +13,9 @@ import kotlinx.coroutines.withContext import org.koin.android.annotation.KoinViewModel @KoinViewModel -class UserDisplayViewModel(private val userDisplayRepository: UserDisplayRepository) : ViewModel() { +class UserDisplayViewModel( + private val userDisplayRepository: UserDisplayRepository, +) : ViewModel() { private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() @@ -24,37 +26,44 @@ class UserDisplayViewModel(private val userDisplayRepository: UserDisplayReposit userDisplays.replaceAll(items) } - fun addUserDisplay() = viewModelScope.launch { - val entity = userDisplayRepository.addUserDisplay() - userDisplays += entity.toItem() - val position = userDisplays.lastIndex - sendEvent(UserDisplayEvent.ItemAdded(position, isLast = true)) - } + fun addUserDisplay() = + viewModelScope.launch { + val entity = userDisplayRepository.addUserDisplay() + userDisplays += entity.toItem() + 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 sendEvent(UserDisplayEvent.ItemAdded(position, isLast)) } - fun removeUserDisplayItem(item: UserDisplayItem) = viewModelScope.launch { - val position = userDisplays.indexOfFirst { it.id == item.id } - if (position == -1) { - return@launch - } + fun removeUserDisplayItem(item: UserDisplayItem) = + viewModelScope.launch { + val position = userDisplays.indexOfFirst { it.id == item.id } + if (position == -1) { + return@launch + } - userDisplayRepository.removeUserDisplay(item.toEntity()) - userDisplays.removeAt(position) - sendEvent(UserDisplayEvent.ItemRemoved(item, position)) - } + userDisplayRepository.removeUserDisplay(item.toEntity()) + userDisplays.removeAt(position) + sendEvent(UserDisplayEvent.ItemRemoved(item, position)) + } - fun updateUserDisplays(userDisplayItems: List) = viewModelScope.launch { - val entries = userDisplayItems.map(UserDisplayItem::toEntity) - userDisplayRepository.updateUserDisplays(entries) - } + fun updateUserDisplays(userDisplayItems: List) = + viewModelScope.launch { + val entries = userDisplayItems.map(UserDisplayItem::toEntity) + userDisplayRepository.updateUserDisplays(entries) + } - private suspend fun sendEvent(event: UserDisplayEvent) = withContext(Dispatchers.Main.immediate) { - eventChannel.send(event) - } + private suspend fun sendEvent(event: UserDisplayEvent) = + withContext(Dispatchers.Main.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 2b7edfe16..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 @@ -18,20 +18,27 @@ import androidx.compose.ui.unit.dp @Suppress("LambdaParameterEventTrailing") @Composable -fun CheckboxWithText(text: String, checked: Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit) { +fun CheckboxWithText( + text: String, + checked: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, +) { 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/PreferenceCategory.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceCategory.kt index 4b6024b24..f0c3cf8be 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 @@ -18,7 +18,10 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.ui.theme.DankChatTheme @Composable -fun PreferenceCategory(title: String, content: @Composable ColumnScope.() -> Unit) { +fun PreferenceCategory( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { Column( modifier = Modifier.padding(top = 16.dp), ) { @@ -30,7 +33,10 @@ fun PreferenceCategory(title: String, content: @Composable ColumnScope.() -> Uni } @Composable -fun PreferenceCategoryWithSummary(title: @Composable () -> Unit, summary: @Composable () -> Unit) { +fun PreferenceCategoryWithSummary( + title: @Composable () -> Unit, + summary: @Composable () -> Unit, +) { Column( modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), ) { @@ -40,7 +46,10 @@ fun PreferenceCategoryWithSummary(title: @Composable () -> Unit, summary: @Compo } @Composable -fun PreferenceCategoryTitle(text: String, modifier: Modifier = Modifier) { +fun PreferenceCategoryTitle( + text: String, + modifier: Modifier = Modifier, +) { Text( text = text, style = MaterialTheme.typography.titleSmall, @@ -52,7 +61,9 @@ fun PreferenceCategoryTitle(text: String, modifier: Modifier = Modifier) { @Suppress("UnusedPrivateMember") @Composable @PreviewLightDark -private fun PreferenceCategoryPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { +private fun PreferenceCategoryPreview( + @PreviewParameter(provider = LoremIpsum::class) loremIpsum: String, +) { DankChatTheme { Surface { PreferenceCategoryWithSummary( @@ -66,7 +77,9 @@ private fun PreferenceCategoryPreview(@PreviewParameter(provider = LoremIpsum::c @Suppress("UnusedPrivateMember") @Composable @PreviewLightDark -private fun PreferenceCategoryWithItemsPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { +private fun PreferenceCategoryWithItemsPreview( + @PreviewParameter(provider = LoremIpsum::class) loremIpsum: String, +) { DankChatTheme { Surface { PreferenceCategory( 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 abcf08c1c..53113f3b8 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 @@ -40,7 +40,13 @@ import com.flxrs.dankchat.utils.compose.ContentAlpha import kotlin.math.roundToInt @Composable -fun SwitchPreferenceItem(title: String, isChecked: Boolean, onClick: (Boolean) -> Unit, isEnabled: Boolean = true, summary: String? = null) { +fun SwitchPreferenceItem( + title: String, + isChecked: Boolean, + onClick: (Boolean) -> Unit, + isEnabled: Boolean = true, + summary: String? = null, +) { val interactionSource = remember { MutableInteractionSource() } HorizontalPreferenceItemWrapper( title = title, @@ -58,18 +64,26 @@ interface ExpandablePreferenceScope { } @Composable -fun ExpandablePreferenceItem(title: String, icon: ImageVector? = null, isEnabled: Boolean = true, summary: String? = null, content: @Composable ExpandablePreferenceScope.() -> Unit) { +fun ExpandablePreferenceItem( + title: String, + icon: ImageVector? = null, + isEnabled: Boolean = true, + summary: String? = null, + 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, @@ -112,9 +126,10 @@ fun SliderPreferenceItem( onValueChangeFinished = onDragFinish, valueRange = range, steps = steps, - modifier = Modifier - .weight(1f) - .padding(top = 4.dp), + modifier = + Modifier + .weight(1f) + .padding(top = 4.dp), ) if (displayValue) { Text( @@ -129,23 +144,32 @@ fun SliderPreferenceItem( } @Composable -fun PreferenceItem(title: String, icon: ImageVector? = null, trailingIcon: ImageVector? = null, isEnabled: Boolean = true, summary: String? = null, onClick: () -> Unit = { }) { +fun PreferenceItem( + title: String, + icon: ImageVector? = null, + trailingIcon: ImageVector? = null, + isEnabled: Boolean = true, + summary: String? = null, + onClick: () -> Unit = { }, +) { HorizontalPreferenceItemWrapper( title = title, icon = icon, 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) - } - }, + }, ) } @@ -160,16 +184,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( @@ -196,16 +220,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( @@ -219,12 +243,19 @@ private fun VerticalPreferenceItemWrapper( } @Composable -private fun RowScope.PreferenceItemContent(title: String, isEnabled: Boolean, icon: ImageVector?, summary: String?, textPaddingValues: PaddingValues = PaddingValues(vertical = 16.dp)) { +private fun RowScope.PreferenceItemContent( + title: String, + isEnabled: Boolean, + icon: ImageVector?, + summary: String?, + 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, 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 2185df08c..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 fc9f6abd5..186a4f553 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 @@ -12,7 +12,12 @@ import androidx.compose.ui.graphics.Color import kotlinx.coroutines.launch @Composable -fun PreferenceTabRow(appBarContainerColor: State, pagerState: PagerState, tabCount: Int, tabText: @Composable (Int) -> String) { +fun PreferenceTabRow( + appBarContainerColor: State, + pagerState: PagerState, + tabCount: Int, + tabText: @Composable (Int) -> String, +) { val scope = rememberCoroutineScope() PrimaryTabRow( containerColor = appBarContainerColor.value, 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 e9cbb4d18..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 @@ -18,8 +18,13 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class DeveloperSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { - private enum class DeveloperPreferenceKeys(override val id: Int) : PreferenceKeys { +class DeveloperSettingsDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + 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), 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 index b5fac12f8..8166b9eaf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -97,11 +97,12 @@ fun DeveloperSettingsScreen(onBack: () -> Unit) { viewModel.events.collectLatest { when (it) { DeveloperSettingsEvent.RestartRequired -> { - val result = snackbarHostState.showSnackbar( - message = restartRequiredTitle, - actionLabel = restartRequiredAction, - duration = SnackbarDuration.Long, - ) + val result = + snackbarHostState.showSnackbar( + message = restartRequiredTitle, + actionLabel = restartRequiredAction, + duration = SnackbarDuration.Long, + ) if (result == SnackbarResult.ActionPerformed) { ProcessPhoenix.triggerRebirth(context) } @@ -124,7 +125,12 @@ fun DeveloperSettingsScreen(onBack: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DeveloperSettingsContent(settings: DeveloperSettings, snackbarHostState: SnackbarHostState, onInteraction: (DeveloperSettingsInteraction) -> Unit, onBack: () -> Unit) { +private fun DeveloperSettingsContent( + settings: DeveloperSettings, + snackbarHostState: SnackbarHostState, + onInteraction: (DeveloperSettingsInteraction) -> Unit, + onBack: () -> Unit, +) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), @@ -144,10 +150,11 @@ private fun DeveloperSettingsContent(settings: DeveloperSettings, snackbarHostSt }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { PreferenceCategory(title = stringResource(R.string.preference_developer_category_general)) { SwitchPreferenceItem( @@ -185,10 +192,11 @@ private fun DeveloperSettingsContent(settings: DeveloperSettings, snackbarHostSt summary = stringResource(R.string.preference_helix_sending_summary), isChecked = settings.chatSendProtocol == ChatSendProtocol.Helix, onClick = { enabled -> - val protocol = when { - enabled -> ChatSendProtocol.Helix - else -> ChatSendProtocol.IRC - } + val protocol = + when { + enabled -> ChatSendProtocol.Helix + else -> ChatSendProtocol.IRC + } onInteraction(DeveloperSettingsInteraction.ChatSendProtocolChanged(protocol)) }, ) @@ -246,7 +254,10 @@ private fun DeveloperSettingsContent(settings: DeveloperSettings, snackbarHostSt @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CustomRecentMessagesHostBottomSheet(initialHost: String, onInteraction: (DeveloperSettingsInteraction) -> Unit) { +private fun CustomRecentMessagesHostBottomSheet( + initialHost: String, + onInteraction: (DeveloperSettingsInteraction) -> Unit, +) { var host by remember(initialHost) { mutableStateOf(initialHost) } ModalBottomSheet( onDismissRequest = { onInteraction(DeveloperSettingsInteraction.CustomRecentMessagesHost(host)) }, @@ -256,30 +267,34 @@ private fun CustomRecentMessagesHostBottomSheet(initialHost: String, onInteracti 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), + 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), + modifier = + Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), ) OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + 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, - ), + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), ) Spacer(Modifier.height(64.dp)) } @@ -287,20 +302,24 @@ private fun CustomRecentMessagesHostBottomSheet(initialHost: String, onInteracti @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CustomLoginBottomSheet(onDismissRequest: () -> Unit, onRequestRestart: () -> Unit) { +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 - } + 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) { @@ -317,23 +336,26 @@ private fun CustomLoginBottomSheet(onDismissRequest: () -> Unit, onRequestRestar text = stringResource(R.string.preference_custom_login_title), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + 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), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), ) Row( horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.End), + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.End), ) { TextButton( onClick = { showScopesDialog = true }, @@ -347,14 +369,16 @@ private fun CustomLoginBottomSheet(onDismissRequest: () -> Unit, onRequestRestar var showPassword by remember { mutableStateOf(false) } androidx.compose.material3.OutlinedSecureTextField( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), state = token, - textObfuscationMode = when { - showPassword -> TextObfuscationMode.Visible - else -> TextObfuscationMode.Hidden - }, + textObfuscationMode = + when { + showPassword -> TextObfuscationMode.Visible + else -> TextObfuscationMode.Hidden + }, label = { Text(stringResource(R.string.oauth_token)) }, isError = error != null, supportingText = { error?.let { Text(it) } }, @@ -369,11 +393,12 @@ private fun CustomLoginBottomSheet(onDismissRequest: () -> Unit, onRequestRestar }, ) }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - ), + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), ) AnimatedVisibility(visible = state !is CustomLoginState.Loading, modifier = Modifier.fillMaxWidth()) { @@ -419,7 +444,10 @@ private fun CustomLoginBottomSheet(onDismissRequest: () -> Unit, onRequestRestar @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ShowScopesBottomSheet(scopes: String, onDismissRequest: () -> Unit) { +private fun ShowScopesBottomSheet( + scopes: String, + onDismissRequest: () -> Unit, +) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() ModalBottomSheet( @@ -432,9 +460,10 @@ private fun ShowScopesBottomSheet(scopes: String, onDismissRequest: () -> Unit) text = stringResource(R.string.custom_login_required_scopes), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), ) OutlinedTextField( value = scopes, @@ -457,7 +486,11 @@ private fun ShowScopesBottomSheet(scopes: String, onDismissRequest: () -> Unit) } @Composable -private fun MissingScopesDialog(missing: String, onDismissRequest: () -> Unit, onContinue: () -> Unit) { +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), 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 index d4d0db9c7..113830ace 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt @@ -7,19 +7,33 @@ sealed interface DeveloperSettingsEvent { } sealed interface DeveloperSettingsInteraction { - data class DebugMode(val value: Boolean) : DeveloperSettingsInteraction + data class DebugMode( + val value: Boolean, + ) : DeveloperSettingsInteraction - data class RepeatedSending(val value: Boolean) : DeveloperSettingsInteraction + data class RepeatedSending( + val value: Boolean, + ) : DeveloperSettingsInteraction - data class BypassCommandHandling(val value: Boolean) : DeveloperSettingsInteraction + data class BypassCommandHandling( + val value: Boolean, + ) : DeveloperSettingsInteraction - data class CustomRecentMessagesHost(val host: String) : DeveloperSettingsInteraction + data class CustomRecentMessagesHost( + val host: String, + ) : DeveloperSettingsInteraction - data class EventSubEnabled(val value: Boolean) : DeveloperSettingsInteraction + data class EventSubEnabled( + val value: Boolean, + ) : DeveloperSettingsInteraction - data class EventSubDebugOutput(val value: Boolean) : DeveloperSettingsInteraction + data class EventSubDebugOutput( + val value: Boolean, + ) : DeveloperSettingsInteraction - data class ChatSendProtocolChanged(val protocol: ChatSendProtocol) : DeveloperSettingsInteraction + data class ChatSendProtocolChanged( + val protocol: ChatSendProtocol, + ) : DeveloperSettingsInteraction data object RestartRequired : 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 9391b06c2..eba47e7e1 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 @@ -35,79 +35,80 @@ class DeveloperSettingsViewModel( private val _events = MutableSharedFlow() val events = _events.asSharedFlow() - 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) } - } + fun onInteraction(interaction: DeveloperSettingsInteraction) = + viewModelScope.launch { + runCatching { + when (interaction) { + is DeveloperSettingsInteraction.DebugMode -> { + developerSettingsDataStore.update { it.copy(debugMode = interaction.value) } + } - is DeveloperSettingsInteraction.BypassCommandHandling -> { - developerSettingsDataStore.update { it.copy(bypassCommandHandling = interaction.value) } - } + is DeveloperSettingsInteraction.RepeatedSending -> { + developerSettingsDataStore.update { it.copy(repeatedSending = interaction.value) } + } - is DeveloperSettingsInteraction.CustomRecentMessagesHost -> { - 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.BypassCommandHandling -> { + developerSettingsDataStore.update { it.copy(bypassCommandHandling = interaction.value) } + } - is DeveloperSettingsInteraction.EventSubEnabled -> { - developerSettingsDataStore.update { it.copy(eventSubEnabled = interaction.value) } - if (initial.eventSubEnabled != interaction.value) { + is DeveloperSettingsInteraction.CustomRecentMessagesHost -> { + 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.EventSubDebugOutput -> { - developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } - } + is DeveloperSettingsInteraction.EventSubEnabled -> { + developerSettingsDataStore.update { it.copy(eventSubEnabled = interaction.value) } + if (initial.eventSubEnabled != interaction.value) { + _events.emit(DeveloperSettingsEvent.RestartRequired) + } + } - is DeveloperSettingsInteraction.ChatSendProtocolChanged -> { - developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } - } + is DeveloperSettingsInteraction.EventSubDebugOutput -> { + developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } + } - is DeveloperSettingsInteraction.RestartRequired -> { - _events.emit(DeveloperSettingsEvent.RestartRequired) - } + is DeveloperSettingsInteraction.ChatSendProtocolChanged -> { + developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } + } - is DeveloperSettingsInteraction.ResetOnboarding -> { - onboardingDataStore.update { - it.copy( - hasCompletedOnboarding = false, - onboardingPage = 0, - ) + is DeveloperSettingsInteraction.RestartRequired -> { + _events.emit(DeveloperSettingsEvent.RestartRequired) } - _events.emit(DeveloperSettingsEvent.RestartRequired) - } - is DeveloperSettingsInteraction.ResetTour -> { - onboardingDataStore.update { - it.copy( - featureTourVersion = 0, - featureTourStep = 0, - hasShownAddChannelHint = false, - hasShownToolbarHint = false, - ) + is DeveloperSettingsInteraction.ResetOnboarding -> { + onboardingDataStore.update { + it.copy( + hasCompletedOnboarding = false, + onboardingPage = 0, + ) + } + _events.emit(DeveloperSettingsEvent.RestartRequired) } - _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) + 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 e3671d0d5..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 @@ -13,7 +13,14 @@ sealed interface CustomLoginState { object TokenInvalid : CustomLoginState - data class MissingScopes(val missingScopes: String, val validation: ValidateDto, val token: String, val dialogOpen: Boolean) : CustomLoginState - - data class Failure(val error: String) : CustomLoginState + data class MissingScopes( + val missingScopes: String, + val validation: ValidateDto, + val token: String, + val dialogOpen: Boolean, + ) : 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 92497b740..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 @@ -19,7 +19,10 @@ import kotlinx.coroutines.flow.update import org.koin.core.annotation.Factory @Factory -class CustomLoginViewModel(private val authApiClient: AuthApiClient, private val authDataStore: AuthDataStore) { +class CustomLoginViewModel( + private val authApiClient: AuthApiClient, + private val authDataStore: AuthDataStore, +) { private val _customLoginState = MutableStateFlow(Default) val customLoginState = _customLoginState.asStateFlow() @@ -67,7 +70,10 @@ class CustomLoginViewModel(private val authApiClient: AuthApiClient, private val _customLoginState.update { (it as? MissingScopes)?.copy(dialogOpen = false) ?: it } } - fun saveLogin(token: String, validateDto: ValidateDto) { + fun saveLogin( + token: String, + validateDto: ValidateDto, + ) { authDataStore.updateAsync { it.copy( oAuthKey = "oauth:$token", 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 4e8b8a4a4..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 @@ -3,9 +3,15 @@ package com.flxrs.dankchat.preferences.notifications import kotlinx.serialization.Serializable @Serializable -data class NotificationsSettings(val showNotifications: Boolean = true, val showWhisperNotifications: Boolean = true, val mentionFormat: MentionFormat = MentionFormat.Name) +data class NotificationsSettings( + val showNotifications: Boolean = true, + val showWhisperNotifications: Boolean = true, + val mentionFormat: MentionFormat = MentionFormat.Name, +) -enum class MentionFormat(val template: String) { +enum class MentionFormat( + val template: String, +) { Name("name"), NameComma("name,"), AtName("@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 19c57f212..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 @@ -19,8 +19,13 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class NotificationsSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { - private enum class NotificationsPreferenceKeys(override val id: Int) : PreferenceKeys { +class NotificationsSettingsDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + 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), @@ -40,9 +45,9 @@ class NotificationsSettingsDataStore(context: Context, dispatchersProvider: Disp NotificationsPreferenceKeys.MentionFormat -> { acc.copy( mentionFormat = - value.stringOrNull()?.let { format -> - MentionFormat.entries.find { it.template == format } - } ?: acc.mentionFormat, + value.stringOrNull()?.let { format -> + MentionFormat.entries.find { it.template == format } + } ?: acc.mentionFormat, ) } } 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 664726f22..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 @@ -36,7 +36,11 @@ import kotlinx.collections.immutable.toImmutableList import org.koin.compose.viewmodel.koinViewModel @Composable -fun NotificationsSettingsScreen(onNavToHighlights: () -> Unit, onNavToIgnores: () -> Unit, onNavBack: () -> Unit) { +fun NotificationsSettingsScreen( + onNavToHighlights: () -> Unit, + onNavToIgnores: () -> Unit, + onNavBack: () -> Unit, +) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value NotificationsSettingsScreen( @@ -74,10 +78,11 @@ private fun NotificationsSettingsScreen( }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { NotificationsCategory( showNotifications = settings.showNotifications, @@ -97,7 +102,11 @@ private fun NotificationsSettingsScreen( } @Composable -fun NotificationsCategory(showNotifications: Boolean, showWhisperNotifications: Boolean, onInteraction: (NotificationsSettingsInteraction) -> Unit) { +fun NotificationsCategory( + showNotifications: Boolean, + showWhisperNotifications: Boolean, + onInteraction: (NotificationsSettingsInteraction) -> Unit, +) { PreferenceCategory(title = stringResource(R.string.preference_notification_header)) { SwitchPreferenceItem( title = stringResource(R.string.preference_notification_title), @@ -115,7 +124,12 @@ fun NotificationsCategory(showNotifications: Boolean, showWhisperNotifications: } @Composable -fun MentionsCategory(mentionFormat: MentionFormat, onInteraction: (NotificationsSettingsInteraction) -> Unit, onNavToHighlights: () -> Unit, onNavToIgnores: () -> Unit) { +fun MentionsCategory( + mentionFormat: MentionFormat, + onInteraction: (NotificationsSettingsInteraction) -> Unit, + onNavToHighlights: () -> Unit, + onNavToIgnores: () -> Unit, +) { PreferenceCategory(title = stringResource(R.string.mentions)) { val entries = remember { MentionFormat.entries.map { it.template }.toImmutableList() } PreferenceListDialog( 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 a229f4fed..88a091455 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 @@ -10,7 +10,9 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class NotificationsSettingsViewModel(private val notificationsSettingsDataStore: NotificationsSettingsDataStore) : ViewModel() { +class NotificationsSettingsViewModel( + private val notificationsSettingsDataStore: NotificationsSettingsDataStore, +) : ViewModel() { val settings = notificationsSettingsDataStore.settings .stateIn( @@ -19,21 +21,28 @@ class NotificationsSettingsViewModel(private val notificationsSettingsDataStore: initialValue = notificationsSettingsDataStore.current(), ) - fun onInteraction(interaction: NotificationsSettingsInteraction) = viewModelScope.launch { - runCatching { - when (interaction) { - 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) } + fun onInteraction(interaction: NotificationsSettingsInteraction) = + viewModelScope.launch { + runCatching { + when (interaction) { + 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) } + } } } - } } sealed interface NotificationsSettingsInteraction { - data class Notifications(val value: Boolean) : NotificationsSettingsInteraction + data class Notifications( + val value: Boolean, + ) : NotificationsSettingsInteraction - data class WhisperNotifications(val value: Boolean) : NotificationsSettingsInteraction + data class WhisperNotifications( + val value: Boolean, + ) : NotificationsSettingsInteraction - data class Mention(val value: MentionFormat) : 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 04d72bb11..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 @@ -6,10 +6,18 @@ import kotlinx.coroutines.flow.Flow @Immutable sealed interface HighlightEvent { - data class ItemRemoved(val item: HighlightItem, val position: Int) : HighlightEvent + data class ItemRemoved( + val item: HighlightItem, + val position: Int, + ) : HighlightEvent - data class ItemAdded(val position: Int, val isLast: Boolean) : 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 5172404ce..ea7758437 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 @@ -40,8 +40,14 @@ data class MessageHighlightItem( } } -data class UserHighlightItem(override val id: Long, val enabled: Boolean, val username: String, val createNotification: Boolean, val notificationsEnabled: Boolean, val customColor: Int?) : - HighlightItem +data class UserHighlightItem( + override val id: Long, + val enabled: Boolean, + val username: String, + val createNotification: Boolean, + val notificationsEnabled: Boolean, + val customColor: Int?, +) : HighlightItem data class BadgeHighlightItem( override val id: Long, @@ -53,9 +59,17 @@ data class BadgeHighlightItem( val notificationsEnabled: Boolean, ) : HighlightItem -data class BlacklistedUserItem(override val id: Long, val enabled: Boolean, val username: String, val isRegex: Boolean) : HighlightItem +data class BlacklistedUserItem( + override val id: Long, + val enabled: Boolean, + val username: String, + 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(), @@ -68,85 +82,94 @@ fun MessageHighlightEntity.toItem(loggedIn: Boolean, notificationsEnabled: Boole customColor = customColor, ) -fun MessageHighlightItem.toEntity() = MessageHighlightEntity( - id = id, - enabled = enabled, - type = type.toEntityType(), - pattern = pattern, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - createNotification = createNotification, - customColor = customColor, -) - -fun MessageHighlightItem.Type.toEntityType(): MessageHighlightEntityType = when (this) { - MessageHighlightItem.Type.Username -> MessageHighlightEntityType.Username - MessageHighlightItem.Type.Subscription -> MessageHighlightEntityType.Subscription - MessageHighlightItem.Type.Announcement -> MessageHighlightEntityType.Announcement - 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 -} - -fun MessageHighlightEntityType.toItemType(): MessageHighlightItem.Type = when (this) { - MessageHighlightEntityType.Username -> MessageHighlightItem.Type.Username - MessageHighlightEntityType.Subscription -> MessageHighlightItem.Type.Subscription - MessageHighlightEntityType.Announcement -> MessageHighlightItem.Type.Announcement - 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 -} - -fun UserHighlightEntity.toItem(notificationsEnabled: Boolean) = UserHighlightItem( - id = id, - enabled = enabled, - username = username, - createNotification = createNotification, - notificationsEnabled = notificationsEnabled, - customColor = customColor, -) - -fun UserHighlightItem.toEntity() = UserHighlightEntity( - id = id, - enabled = enabled, - username = username, - createNotification = createNotification, - customColor = customColor, -) - -fun BadgeHighlightEntity.toItem(notificationsEnabled: Boolean) = BadgeHighlightItem( - id = id, - enabled = enabled, - badgeName = badgeName, - isCustom = isCustom, - customColor = customColor, - createNotification = createNotification, - notificationsEnabled = notificationsEnabled, -) - -fun BadgeHighlightItem.toEntity() = BadgeHighlightEntity( - id = id, - enabled = enabled, - badgeName = badgeName, - isCustom = isCustom, - customColor = customColor, - createNotification = createNotification, -) +fun MessageHighlightItem.toEntity() = + MessageHighlightEntity( + id = id, + enabled = enabled, + type = type.toEntityType(), + pattern = pattern, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + createNotification = createNotification, + customColor = customColor, + ) + +fun MessageHighlightItem.Type.toEntityType(): MessageHighlightEntityType = + when (this) { + MessageHighlightItem.Type.Username -> MessageHighlightEntityType.Username + MessageHighlightItem.Type.Subscription -> MessageHighlightEntityType.Subscription + MessageHighlightItem.Type.Announcement -> MessageHighlightEntityType.Announcement + 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 + } -fun BlacklistedUserEntity.toItem() = BlacklistedUserItem( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, -) +fun MessageHighlightEntityType.toItemType(): MessageHighlightItem.Type = + when (this) { + MessageHighlightEntityType.Username -> MessageHighlightItem.Type.Username + MessageHighlightEntityType.Subscription -> MessageHighlightItem.Type.Subscription + MessageHighlightEntityType.Announcement -> MessageHighlightItem.Type.Announcement + 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 + } -fun BlacklistedUserItem.toEntity() = BlacklistedUserEntity( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, -) +fun UserHighlightEntity.toItem(notificationsEnabled: Boolean) = + UserHighlightItem( + id = id, + enabled = enabled, + username = username, + createNotification = createNotification, + notificationsEnabled = notificationsEnabled, + customColor = customColor, + ) + +fun UserHighlightItem.toEntity() = + UserHighlightEntity( + id = id, + enabled = enabled, + username = username, + createNotification = createNotification, + customColor = customColor, + ) + +fun BadgeHighlightEntity.toItem(notificationsEnabled: Boolean) = + BadgeHighlightItem( + id = id, + enabled = enabled, + badgeName = badgeName, + isCustom = isCustom, + customColor = customColor, + createNotification = createNotification, + notificationsEnabled = notificationsEnabled, + ) + +fun BadgeHighlightItem.toEntity() = + BadgeHighlightEntity( + id = id, + enabled = enabled, + badgeName = badgeName, + isCustom = isCustom, + customColor = customColor, + createNotification = createNotification, + ) + +fun BlacklistedUserEntity.toItem() = + BlacklistedUserItem( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, + ) + +fun BlacklistedUserItem.toEntity() = + BlacklistedUserEntity( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, + ) 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 f67e1e6b2..4b076fe51 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 @@ -153,11 +153,12 @@ private fun HighlightsScreen( focusManager.clearFocus() when (event) { is HighlightEvent.ItemRemoved -> { - val result = snackbarHost.showSnackbar( - message = itemRemovedMsg, - actionLabel = undoMsg, - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { onAdd(event.item, event.position) } @@ -183,9 +184,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( @@ -207,9 +209,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, ) } @@ -218,21 +221,24 @@ 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, ) @@ -253,70 +259,83 @@ private fun HighlightsScreen( 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, - onChange = { 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, - onChange = { 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, - onChange = { 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, - onChange = { 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, + ), + ) + } } } } @@ -325,7 +344,11 @@ private fun HighlightsScreen( } @Composable -private fun HighlightsList(highlights: SnapshotStateList, listState: LazyListState, itemContent: @Composable LazyItemScope.(Int, T) -> Unit) { +private fun HighlightsList( + highlights: SnapshotStateList, + listState: LazyListState, + itemContent: @Composable LazyItemScope.(Int, T) -> Unit, +) { DankBackground(visible = highlights.isEmpty()) LazyColumn( @@ -346,24 +369,31 @@ private fun HighlightsList(highlights: SnapshotStateList, } @Composable -private fun MessageHighlightItem(item: MessageHighlightItem, onChange: (MessageHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun MessageHighlightItem( + item: MessageHighlightItem, + 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.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), @@ -380,9 +410,10 @@ private fun MessageHighlightItem(item: MessageHighlightItem, onChange: (MessageH } if (isCustom) { OutlinedTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), value = item.pattern, onValueChange = { onChange(item.copy(pattern = it)) }, label = { Text(stringResource(R.string.pattern)) }, @@ -391,9 +422,10 @@ private fun MessageHighlightItem(item: MessageHighlightItem, onChange: (MessageH ) } FlowRow( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), verticalArrangement = Arrangement.Center, ) { CheckboxWithText( @@ -413,9 +445,10 @@ private fun MessageHighlightItem(item: MessageHighlightItem, onChange: (MessageH 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), @@ -435,14 +468,15 @@ private fun MessageHighlightItem(item: MessageHighlightItem, onChange: (MessageH ) } } - 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 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) + } HighlightColorPicker( color = item.customColor ?: defaultColor, defaultColor = defaultColor, @@ -453,13 +487,19 @@ private fun MessageHighlightItem(item: MessageHighlightItem, onChange: (MessageH } @Composable -private fun UserHighlightItem(item: UserHighlightItem, onChange: (UserHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun UserHighlightItem( + item: UserHighlightItem, + 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(), @@ -503,13 +543,19 @@ private fun UserHighlightItem(item: UserHighlightItem, onChange: (UserHighlightI } @Composable -private fun BadgeHighlightItem(item: BadgeHighlightItem, onChange: (BadgeHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun BadgeHighlightItem( + item: BadgeHighlightItem, + 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( @@ -534,9 +580,10 @@ private fun BadgeHighlightItem(item: BadgeHighlightItem, onChange: (BadgeHighlig "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, @@ -581,14 +628,20 @@ private fun BadgeHighlightItem(item: BadgeHighlightItem, onChange: (BadgeHighlig } @Composable -private fun BlacklistedUserItem(item: BlacklistedUserItem, onChange: (BlacklistedUserItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun BlacklistedUserItem( + item: BlacklistedUserItem, + onChange: (BlacklistedUserItem) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { val launcher = LocalUriHandler.current ElevatedCard(modifier) { Row { Column( - modifier = Modifier - .weight(1f) - .padding(8.dp), + modifier = + Modifier + .weight(1f) + .padding(8.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), @@ -630,7 +683,12 @@ private fun BlacklistedUserItem(item: BlacklistedUserItem, onChange: (Blackliste } @Composable -private fun HighlightColorPicker(color: Int, defaultColor: Int, enabled: Boolean, onColorSelect: (Int) -> Unit) { +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( @@ -661,9 +719,10 @@ private fun HighlightColorPicker(color: Int, defaultColor: Int, enabled: Boolean 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), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), ) Row( modifier = Modifier.fillMaxWidth(), 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 726f70e83..c231d79f0 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 @@ -39,53 +39,58 @@ class HighlightsViewModel( _currentTab.value = HighlightsTab.entries[position] } - fun fetchHighlights() = viewModelScope.launch { - val loggedIn = preferenceStore.isLoggedIn - val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications - val messageHighlightItems = highlightsRepository.messageHighlights.value.map { it.toItem(loggedIn, notificationsEnabled) } - val userHighlightItems = highlightsRepository.userHighlights.value.map { it.toItem(notificationsEnabled) } - val badgeHighlightItems = highlightsRepository.badgeHighlights.value.map { it.toItem(notificationsEnabled) } - val blacklistedUserItems = highlightsRepository.blacklistedUsers.value.map { it.toItem() } - - messageHighlights.replaceAll(messageHighlightItems) - userHighlights.replaceAll(userHighlightItems) - badgeHighlights.replaceAll(badgeHighlightItems) - blacklistedUsers.replaceAll(blacklistedUserItems) - } - - fun addHighlight() = viewModelScope.launch { - val loggedIn = preferenceStore.isLoggedIn - val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications - val position: Int - when (_currentTab.value) { - HighlightsTab.Messages -> { - val entity = highlightsRepository.addMessageHighlight() - messageHighlights += entity.toItem(loggedIn, notificationsEnabled) - position = messageHighlights.lastIndex - } - - HighlightsTab.Users -> { - val entity = highlightsRepository.addUserHighlight() - userHighlights += entity.toItem(notificationsEnabled) - position = userHighlights.lastIndex - } - - HighlightsTab.Badges -> { - val entity = highlightsRepository.addBadgeHighlight() - badgeHighlights += entity.toItem(notificationsEnabled) - position = badgeHighlights.lastIndex - } + fun fetchHighlights() = + viewModelScope.launch { + val loggedIn = preferenceStore.isLoggedIn + val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications + val messageHighlightItems = highlightsRepository.messageHighlights.value.map { it.toItem(loggedIn, notificationsEnabled) } + val userHighlightItems = highlightsRepository.userHighlights.value.map { it.toItem(notificationsEnabled) } + val badgeHighlightItems = highlightsRepository.badgeHighlights.value.map { it.toItem(notificationsEnabled) } + val blacklistedUserItems = highlightsRepository.blacklistedUsers.value.map { it.toItem() } + + messageHighlights.replaceAll(messageHighlightItems) + userHighlights.replaceAll(userHighlightItems) + badgeHighlights.replaceAll(badgeHighlightItems) + blacklistedUsers.replaceAll(blacklistedUserItems) + } - HighlightsTab.BlacklistedUsers -> { - val entity = highlightsRepository.addBlacklistedUser() - blacklistedUsers += entity.toItem() - position = blacklistedUsers.lastIndex + fun addHighlight() = + viewModelScope.launch { + val loggedIn = preferenceStore.isLoggedIn + val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications + val position: Int + when (_currentTab.value) { + HighlightsTab.Messages -> { + val entity = highlightsRepository.addMessageHighlight() + messageHighlights += entity.toItem(loggedIn, notificationsEnabled) + position = messageHighlights.lastIndex + } + + HighlightsTab.Users -> { + val entity = highlightsRepository.addUserHighlight() + userHighlights += entity.toItem(notificationsEnabled) + position = userHighlights.lastIndex + } + + HighlightsTab.Badges -> { + val entity = highlightsRepository.addBadgeHighlight() + badgeHighlights += entity.toItem(notificationsEnabled) + position = badgeHighlights.lastIndex + } + + HighlightsTab.BlacklistedUsers -> { + val entity = highlightsRepository.addBlacklistedUser() + blacklistedUsers += entity.toItem() + position = blacklistedUsers.lastIndex + } } + sendEvent(HighlightEvent.ItemAdded(position, isLast = true)) } - 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 -> { @@ -115,35 +120,36 @@ class HighlightsViewModel( sendEvent(HighlightEvent.ItemAdded(position, isLast)) } - fun removeHighlight(item: HighlightItem) = viewModelScope.launch { - val position: Int - when (item) { - is MessageHighlightItem -> { - position = messageHighlights.indexOfFirst { it.id == item.id } - highlightsRepository.removeMessageHighlight(item.toEntity()) - messageHighlights.removeAt(position) - } - - is UserHighlightItem -> { - position = userHighlights.indexOfFirst { it.id == item.id } - highlightsRepository.removeUserHighlight(item.toEntity()) - userHighlights.removeAt(position) - } - - is BadgeHighlightItem -> { - position = badgeHighlights.indexOfFirst { it.id == item.id } - highlightsRepository.removeBadgeHighlight(item.toEntity()) - badgeHighlights.removeAt(position) - } - - is BlacklistedUserItem -> { - position = blacklistedUsers.indexOfFirst { it.id == item.id } - highlightsRepository.removeBlacklistedUser(item.toEntity()) - blacklistedUsers.removeAt(position) + fun removeHighlight(item: HighlightItem) = + viewModelScope.launch { + val position: Int + when (item) { + is MessageHighlightItem -> { + position = messageHighlights.indexOfFirst { it.id == item.id } + highlightsRepository.removeMessageHighlight(item.toEntity()) + messageHighlights.removeAt(position) + } + + is UserHighlightItem -> { + position = userHighlights.indexOfFirst { it.id == item.id } + highlightsRepository.removeUserHighlight(item.toEntity()) + userHighlights.removeAt(position) + } + + is BadgeHighlightItem -> { + position = badgeHighlights.indexOfFirst { it.id == item.id } + highlightsRepository.removeBadgeHighlight(item.toEntity()) + badgeHighlights.removeAt(position) + } + + is BlacklistedUserItem -> { + position = blacklistedUsers.indexOfFirst { it.id == item.id } + highlightsRepository.removeBlacklistedUser(item.toEntity()) + blacklistedUsers.removeAt(position) + } } + sendEvent(HighlightEvent.ItemRemoved(item, position)) } - sendEvent(HighlightEvent.ItemRemoved(item, position)) - } fun updateHighlights( messageHighlightItems: List, @@ -172,25 +178,30 @@ class HighlightsViewModel( } } - private fun filterMessageHighlights(items: List) = items - .map { it.toEntity() } - .partition { it.type == MessageHighlightEntityType.Custom && it.pattern.isBlank() } - - private fun filterUserHighlights(items: List) = items - .map { it.toEntity() } - .partition { it.username.isBlank() } - - private fun filterBadgeHighlights(items: List) = items - .map { it.toEntity() } - .partition { it.badgeName.isBlank() } - - private fun filterBlacklistedUsers(items: List) = items - .map { it.toEntity() } - .partition { it.username.isBlank() } - - private suspend fun sendEvent(event: HighlightEvent) = withContext(Dispatchers.Main.immediate) { - eventChannel.send(event) - } + private fun filterMessageHighlights(items: List) = + items + .map { it.toEntity() } + .partition { it.type == MessageHighlightEntityType.Custom && it.pattern.isBlank() } + + private fun filterUserHighlights(items: List) = + items + .map { it.toEntity() } + .partition { it.username.isBlank() } + + private fun filterBadgeHighlights(items: List) = + items + .map { it.toEntity() } + .partition { it.badgeName.isBlank() } + + private fun filterBlacklistedUsers(items: List) = + items + .map { it.toEntity() } + .partition { it.username.isBlank() } + + private suspend fun sendEvent(event: HighlightEvent) = + withContext(Dispatchers.Main.immediate) { + eventChannel.send(event) + } companion object { const val REGEX_INFO_URL = "https://wiki.chatterino.com/Regex/" 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 551b5c8eb..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 @@ -6,14 +6,26 @@ import kotlinx.coroutines.flow.Flow @Immutable sealed interface IgnoreEvent { - data class ItemRemoved(val item: IgnoreItem, val position: Int) : IgnoreEvent + data class ItemRemoved( + val item: IgnoreItem, + val position: Int, + ) : IgnoreEvent - data class ItemAdded(val position: Int, val isLast: Boolean) : IgnoreEvent + data class ItemAdded( + val position: Int, + val isLast: Boolean, + ) : IgnoreEvent - data class BlockError(val item: TwitchBlockItem) : IgnoreEvent + data class BlockError( + val item: TwitchBlockItem, + ) : IgnoreEvent - data class UnblockError(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 011a4889b..5e03c3dcf 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 @@ -31,72 +31,89 @@ data class MessageIgnoreItem( } } -data class UserIgnoreItem(override val id: Long, val enabled: Boolean, val username: String, val isRegex: Boolean, val isCaseSensitive: Boolean) : IgnoreItem +data class UserIgnoreItem( + override val id: Long, + val enabled: Boolean, + val username: String, + val isRegex: Boolean, + val isCaseSensitive: Boolean, +) : IgnoreItem -data class TwitchBlockItem(override val id: Long, val username: UserName, val userId: UserId) : IgnoreItem +data class TwitchBlockItem( + override val id: Long, + val username: UserName, + val userId: UserId, +) : IgnoreItem -fun MessageIgnoreEntity.toItem() = MessageIgnoreItem( - id = id, - type = type.toItemType(), - enabled = enabled, - pattern = pattern, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - isBlockMessage = isBlockMessage, - replacement = replacement ?: "", -) +fun MessageIgnoreEntity.toItem() = + MessageIgnoreItem( + id = id, + type = type.toItemType(), + enabled = enabled, + pattern = pattern, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + isBlockMessage = isBlockMessage, + replacement = replacement ?: "", + ) -fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( - id = id, - type = type.toEntityType(), - enabled = enabled, - pattern = pattern, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - isBlockMessage = isBlockMessage, - replacement = - when { - isBlockMessage -> null - else -> replacement - }, -) +fun MessageIgnoreItem.toEntity() = + MessageIgnoreEntity( + id = id, + type = type.toEntityType(), + enabled = enabled, + pattern = pattern, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + isBlockMessage = isBlockMessage, + 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.ChannelPointRedemption -> MessageIgnoreEntityType.ChannelPointRedemption - MessageIgnoreItem.Type.FirstMessage -> MessageIgnoreEntityType.FirstMessage - MessageIgnoreItem.Type.ElevatedMessage -> MessageIgnoreEntityType.ElevatedMessage - MessageIgnoreItem.Type.Custom -> MessageIgnoreEntityType.Custom -} +fun MessageIgnoreItem.Type.toEntityType(): MessageIgnoreEntityType = + when (this) { + MessageIgnoreItem.Type.Subscription -> MessageIgnoreEntityType.Subscription + MessageIgnoreItem.Type.Announcement -> MessageIgnoreEntityType.Announcement + MessageIgnoreItem.Type.ChannelPointRedemption -> MessageIgnoreEntityType.ChannelPointRedemption + 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.ChannelPointRedemption -> MessageIgnoreItem.Type.ChannelPointRedemption - MessageIgnoreEntityType.FirstMessage -> MessageIgnoreItem.Type.FirstMessage - MessageIgnoreEntityType.ElevatedMessage -> MessageIgnoreItem.Type.ElevatedMessage - MessageIgnoreEntityType.Custom -> MessageIgnoreItem.Type.Custom -} +fun MessageIgnoreEntityType.toItemType(): MessageIgnoreItem.Type = + when (this) { + MessageIgnoreEntityType.Subscription -> MessageIgnoreItem.Type.Subscription + MessageIgnoreEntityType.Announcement -> MessageIgnoreItem.Type.Announcement + MessageIgnoreEntityType.ChannelPointRedemption -> MessageIgnoreItem.Type.ChannelPointRedemption + MessageIgnoreEntityType.FirstMessage -> MessageIgnoreItem.Type.FirstMessage + MessageIgnoreEntityType.ElevatedMessage -> MessageIgnoreItem.Type.ElevatedMessage + MessageIgnoreEntityType.Custom -> MessageIgnoreItem.Type.Custom + } -fun UserIgnoreEntity.toItem() = UserIgnoreItem( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, -) +fun UserIgnoreEntity.toItem() = + UserIgnoreItem( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + ) -fun UserIgnoreItem.toEntity() = UserIgnoreEntity( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, -) +fun UserIgnoreItem.toEntity() = + UserIgnoreEntity( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + ) -fun IgnoresRepository.TwitchBlock.toItem() = TwitchBlockItem( - id = id.hashCode().toLong(), - userId = id, - username = name, -) +fun IgnoresRepository.TwitchBlock.toItem() = + TwitchBlockItem( + id = id.hashCode().toLong(), + userId = id, + username = name, + ) 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 1bbd13d9a..2b34ba93f 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 @@ -145,16 +145,18 @@ private fun IgnoresScreen( focusManager.clearFocus() when (event) { is IgnoreEvent.ItemRemoved -> { - val message = when (event.item) { - is TwitchBlockItem -> resources.getString(R.string.unblocked_user, event.item.username) - else -> itemRemovedMsg - } + 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 = undoMsg, - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = message, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { onAdd(event.item, event.position) } @@ -190,9 +192,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( @@ -219,9 +222,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, ) } @@ -231,20 +235,23 @@ 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, ) @@ -264,57 +271,67 @@ private fun IgnoresScreen( 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, - onChange = { 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, - onChange = { 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, + ), + ) + } } } } @@ -323,7 +340,12 @@ private fun IgnoresScreen( } @Composable -private fun IgnoresList(tab: IgnoresTab, ignores: SnapshotStateList, listState: LazyListState, itemContent: @Composable LazyItemScope.(Int, T) -> Unit) { +private fun IgnoresList( + tab: IgnoresTab, + ignores: SnapshotStateList, + listState: LazyListState, + itemContent: @Composable LazyItemScope.(Int, T) -> Unit, +) { DankBackground(visible = ignores.isEmpty()) LazyColumn( @@ -338,32 +360,40 @@ private fun IgnoresList(tab: IgnoresTab, ignores: SnapshotState 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)) } } } @Composable -private fun MessageIgnoreItem(item: MessageIgnoreItem, onChange: (MessageIgnoreItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun MessageIgnoreItem( + item: MessageIgnoreItem, + 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.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 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), @@ -380,9 +410,10 @@ private fun MessageIgnoreItem(item: MessageIgnoreItem, onChange: (MessageIgnoreI } if (isCustom) { OutlinedTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), value = item.pattern, onValueChange = { onChange(item.copy(pattern = it)) }, label = { Text(stringResource(R.string.pattern)) }, @@ -391,9 +422,10 @@ private fun MessageIgnoreItem(item: MessageIgnoreItem, onChange: (MessageIgnoreI ) } FlowRow( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), verticalArrangement = Arrangement.Center, ) { CheckboxWithText( @@ -413,9 +445,10 @@ private fun MessageIgnoreItem(item: MessageIgnoreItem, onChange: (MessageIgnoreI 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), @@ -435,9 +468,10 @@ private fun MessageIgnoreItem(item: MessageIgnoreItem, onChange: (MessageIgnoreI } AnimatedVisibility(visible = isCustom && !item.isBlockMessage) { OutlinedTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), value = item.replacement, onValueChange = { onChange(item.copy(replacement = it)) }, label = { Text(stringResource(R.string.replacement)) }, @@ -449,14 +483,20 @@ private fun MessageIgnoreItem(item: MessageIgnoreItem, onChange: (MessageIgnoreI } @Composable -private fun UserIgnoreItem(item: UserIgnoreItem, onChange: (UserIgnoreItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun UserIgnoreItem( + item: UserIgnoreItem, + onChange: (UserIgnoreItem) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { val launcher = LocalUriHandler.current ElevatedCard(modifier) { Row { Column( - modifier = Modifier - .weight(1f) - .padding(8.dp), + modifier = + Modifier + .weight(1f) + .padding(8.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), @@ -487,9 +527,10 @@ private fun UserIgnoreItem(item: UserIgnoreItem, onChange: (UserIgnoreItem) -> U 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), @@ -509,21 +550,27 @@ private fun UserIgnoreItem(item: UserIgnoreItem, onChange: (UserIgnoreItem) -> U } @Composable -private fun TwitchBlockItem(item: TwitchBlockItem, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun TwitchBlockItem( + item: TwitchBlockItem, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { ElevatedCard(modifier) { Row { 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/IgnoresViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresViewModel.kt index bf55f514d..286d93a3f 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 @@ -16,7 +16,9 @@ import kotlinx.coroutines.withContext import org.koin.android.annotation.KoinViewModel @KoinViewModel -class IgnoresViewModel(private val ignoresRepository: IgnoresRepository) : ViewModel() { +class IgnoresViewModel( + private val ignoresRepository: IgnoresRepository, +) : ViewModel() { private val _currentTab = MutableStateFlow(IgnoresTab.Messages) private val eventChannel = Channel(Channel.BUFFERED) @@ -41,29 +43,33 @@ class IgnoresViewModel(private val ignoresRepository: IgnoresRepository) : ViewM twitchBlocks.replaceAll(twitchBlockItems) } - fun addIgnore() = viewModelScope.launch { - val position: Int - when (_currentTab.value) { - IgnoresTab.Messages -> { - val entity = ignoresRepository.addMessageIgnore() - messageIgnores += entity.toItem() - position = messageIgnores.lastIndex - } + fun addIgnore() = + viewModelScope.launch { + val position: Int + when (_currentTab.value) { + IgnoresTab.Messages -> { + val entity = ignoresRepository.addMessageIgnore() + messageIgnores += entity.toItem() + position = messageIgnores.lastIndex + } - IgnoresTab.Users -> { - val entity = ignoresRepository.addUserIgnore() - userIgnores += entity.toItem() - position = userIgnores.lastIndex - } + 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)) } - 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 -> { @@ -92,36 +98,40 @@ class IgnoresViewModel(private val ignoresRepository: IgnoresRepository) : ViewM sendEvent(IgnoreEvent.ItemAdded(position, isLast)) } - fun removeIgnore(item: IgnoreItem) = viewModelScope.launch { - val position: Int - when (item) { - is MessageIgnoreItem -> { - position = messageIgnores.indexOfFirst { it.id == item.id } - ignoresRepository.removeMessageIgnore(item.toEntity()) - messageIgnores.removeAt(position) - } + fun removeIgnore(item: IgnoreItem) = + viewModelScope.launch { + val position: Int + when (item) { + is MessageIgnoreItem -> { + position = messageIgnores.indexOfFirst { it.id == item.id } + ignoresRepository.removeMessageIgnore(item.toEntity()) + messageIgnores.removeAt(position) + } - is UserIgnoreItem -> { - position = userIgnores.indexOfFirst { it.id == item.id } - ignoresRepository.removeUserIgnore(item.toEntity()) - userIgnores.removeAt(position) - } + is UserIgnoreItem -> { + position = userIgnores.indexOfFirst { it.id == item.id } + ignoresRepository.removeUserIgnore(item.toEntity()) + userIgnores.removeAt(position) + } - 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 + 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 + } } } + sendEvent(IgnoreEvent.ItemRemoved(item, position)) } - sendEvent(IgnoreEvent.ItemRemoved(item, position)) - } - fun updateIgnores(messageIgnoreItems: List, userIgnoreItems: List) = viewModelScope.launch { + fun updateIgnores( + messageIgnoreItems: List, + userIgnoreItems: List, + ) = viewModelScope.launch { filterMessageIgnores(messageIgnoreItems).let { (blankEntities, entities) -> ignoresRepository.updateMessageIgnores(entities) blankEntities.forEach { ignoresRepository.removeMessageIgnore(it) } @@ -133,17 +143,20 @@ class IgnoresViewModel(private val ignoresRepository: IgnoresRepository) : ViewM } } - private fun filterMessageIgnores(items: List) = items - .map { it.toEntity() } - .partition { it.type == MessageIgnoreEntityType.Custom && it.pattern.isBlank() } + private fun filterMessageIgnores(items: List) = + items + .map { it.toEntity() } + .partition { it.type == MessageIgnoreEntityType.Custom && it.pattern.isBlank() } - private fun filterUserIgnores(items: List) = items - .map { it.toEntity() } - .partition { it.username.isBlank() } + private fun filterUserIgnores(items: List) = + items + .map { it.toEntity() } + .partition { it.username.isBlank() } - private suspend fun sendEvent(event: IgnoreEvent) = withContext(Dispatchers.Main.immediate) { - eventChannel.send(event) - } + private suspend fun sendEvent(event: IgnoreEvent) = + withContext(Dispatchers.Main.immediate) { + eventChannel.send(event) + } companion object { const val REGEX_INFO_URL = "https://wiki.chatterino.com/Regex/" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt index 54582299d..ff60fbba5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -52,23 +52,37 @@ private const val TWITCH_TOS_URL = "https://www.twitch.tv/p/terms-of-service" sealed interface SettingsNavigation { data object Appearance : SettingsNavigation + data object Notifications : SettingsNavigation + data object Chat : SettingsNavigation + data object Streams : SettingsNavigation + data object Tools : SettingsNavigation + data object Developer : SettingsNavigation + data object Changelog : SettingsNavigation + data object About : SettingsNavigation } @Composable -fun OverviewSettingsScreen(isLoggedIn: Boolean, hasChangelog: Boolean, onBack: () -> Unit, onLogout: () -> Unit, onNavigate: (SettingsNavigation) -> Unit) { +fun OverviewSettingsScreen( + isLoggedIn: Boolean, + hasChangelog: Boolean, + 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, @@ -83,11 +97,12 @@ fun OverviewSettingsScreen(isLoggedIn: Boolean, hasChangelog: Boolean, onBack: ( }, ) { 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), @@ -130,26 +145,27 @@ fun OverviewSettingsScreen(isLoggedIn: Boolean, hasChangelog: Boolean, onBack: ( ) { 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) + 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)) } } 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 4f6cfed8d..2106789e1 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 @@ -25,9 +25,10 @@ interface SecretDankerScope { @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 } @@ -36,16 +37,18 @@ 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 @@ -54,9 +57,10 @@ 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 -> { 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 6740f9bfe..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 @@ -19,8 +19,13 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class StreamsSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { - private enum class StreamsPreferenceKeys(override val id: Int) : PreferenceKeys { +class StreamsSettingsDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + 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), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt index 55f088deb..aaa21d804 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt @@ -44,7 +44,11 @@ fun StreamsSettingsScreen(onBack: () -> Unit) { } @Composable -private fun StreamsSettingsContent(settings: StreamsSettings, onInteraction: (StreamsSettingsInteraction) -> Unit, onBack: () -> Unit) { +private fun StreamsSettingsContent( + settings: StreamsSettings, + onInteraction: (StreamsSettingsInteraction) -> Unit, + onBack: () -> Unit, +) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), @@ -63,10 +67,11 @@ private fun StreamsSettingsContent(settings: StreamsSettings, onInteraction: (St }, ) { 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), @@ -97,10 +102,11 @@ private fun StreamsSettingsContent(settings: StreamsSettings, onInteraction: (St ) val activity = LocalActivity.current - val pipAvailable = remember { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - activity != null && activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) - } + 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 66910bbf2..282305e82 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 @@ -10,7 +10,9 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class StreamsSettingsViewModel(private val dataStore: StreamsSettingsDataStore) : ViewModel() { +class StreamsSettingsViewModel( + private val dataStore: StreamsSettingsDataStore, +) : ViewModel() { val settings = dataStore.settings.stateIn( scope = viewModelScope, @@ -18,27 +20,38 @@ class StreamsSettingsViewModel(private val dataStore: StreamsSettingsDataStore) 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.PreventStreamReloads -> dataStore.update { it.copy(preventStreamReloads = interaction.value) } - is StreamsSettingsInteraction.EnablePiP -> dataStore.update { it.copy(enablePiP = interaction.value) } + 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.PreventStreamReloads -> dataStore.update { it.copy(preventStreamReloads = interaction.value) } + is StreamsSettingsInteraction.EnablePiP -> dataStore.update { it.copy(enablePiP = interaction.value) } + } } } - } } sealed interface StreamsSettingsInteraction { - data class FetchStreams(val value: Boolean) : StreamsSettingsInteraction + data class FetchStreams( + val value: Boolean, + ) : StreamsSettingsInteraction - data class ShowStreamInfo(val value: Boolean) : StreamsSettingsInteraction + data class ShowStreamInfo( + val value: Boolean, + ) : StreamsSettingsInteraction - data class ShowStreamCategory(val value: Boolean) : StreamsSettingsInteraction + data class ShowStreamCategory( + val value: Boolean, + ) : StreamsSettingsInteraction - data class PreventStreamReloads(val value: Boolean) : StreamsSettingsInteraction + data class PreventStreamReloads( + val value: Boolean, + ) : StreamsSettingsInteraction - data class EnablePiP(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 a436f50d5..5d013cd67 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 @@ -20,7 +20,13 @@ data class ToolsSettings( } @Serializable -data class ImageUploaderConfig(val uploadUrl: String, val formField: String, val headers: String?, val imageLinkPattern: String?, val deletionLinkPattern: String?) { +data class ImageUploaderConfig( + val uploadUrl: String, + val formField: String, + val headers: String?, + val imageLinkPattern: String?, + val deletionLinkPattern: String?, +) { @Transient val parsedHeaders: List> = headers 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 2add1107e..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 @@ -24,8 +24,13 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class ToolsSettingsDataStore(context: Context, dispatchersProvider: DispatchersProvider) { - private enum class ToolsPreferenceKeys(override val id: Int) : PreferenceKeys { +class ToolsSettingsDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + 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), @@ -35,7 +40,9 @@ class ToolsSettingsDataStore(context: Context, dispatchersProvider: DispatchersP 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"), @@ -53,18 +60,18 @@ class ToolsSettingsDataStore(context: Context, dispatchersProvider: DispatchersP ToolsPreferenceKeys.TTSQueue -> { acc.copy( ttsPlayMode = - value.booleanOrNull()?.let { - if (it) TTSPlayMode.Queue else TTSPlayMode.Newest - } ?: acc.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, + value.booleanOrNull()?.let { + if (it) TTSMessageFormat.UserAndMessage else TTSMessageFormat.Message + } ?: acc.ttsMessageFormat, ) } @@ -99,13 +106,13 @@ class ToolsSettingsDataStore(context: Context, dispatchersProvider: DispatchersP 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(), - ), + 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(), + ), ) } 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 dd5a1441f..cd0a15063 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 @@ -83,7 +83,11 @@ import org.koin.compose.viewmodel.koinViewModel import sh.calvin.autolinktext.rememberAutoLinkText @Composable -fun ToolsSettingsScreen(onNavToImageUploader: () -> Unit, onNavToTTSUserIgnoreList: () -> Unit, onNavBack: () -> Unit) { +fun ToolsSettingsScreen( + onNavToImageUploader: () -> Unit, + onNavToTTSUserIgnoreList: () -> Unit, + onNavBack: () -> Unit, +) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value @@ -122,10 +126,11 @@ private fun ToolsSettingsScreen( }, ) { 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) @@ -136,7 +141,10 @@ private fun ToolsSettingsScreen( } @Composable -fun ImageUploaderCategory(hasRecentUploads: Boolean, onNavToImageUploader: () -> Unit) { +fun ImageUploaderCategory( + hasRecentUploads: Boolean, + onNavToImageUploader: () -> Unit, +) { var recentUploadSheetOpen by remember { mutableStateOf(false) } PreferenceCategory(title = stringResource(R.string.preference_uploader_header)) { PreferenceItem( @@ -197,23 +205,26 @@ fun ImageUploaderCategory(hasRecentUploads: Boolean, onNavToImageUploader: () -> @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, @@ -222,16 +233,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), @@ -250,10 +263,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)) } @@ -269,15 +283,20 @@ fun RecentUploadItem(upload: RecentUpload) { } @Composable -fun TextToSpeechCategory(settings: ToolsSettingsState, onInteraction: (ToolsSettingsInteraction) -> Unit, onNavToTTSUserIgnoreList: () -> Unit) { +fun TextToSpeechCategory( + settings: ToolsSettingsState, + onInteraction: (ToolsSettingsInteraction) -> Unit, + onNavToTTSUserIgnoreList: () -> Unit, +) { 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), 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 index 2f41b8e4a..505ba27db 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt @@ -3,19 +3,33 @@ package com.flxrs.dankchat.preferences.tools import kotlinx.collections.immutable.ImmutableSet sealed interface ToolsSettingsInteraction { - data class TTSEnabled(val value: Boolean) : ToolsSettingsInteraction + data class TTSEnabled( + val value: Boolean, + ) : ToolsSettingsInteraction - data class TTSMode(val value: TTSPlayMode) : ToolsSettingsInteraction + data class TTSMode( + val value: TTSPlayMode, + ) : ToolsSettingsInteraction - data class TTSFormat(val value: TTSMessageFormat) : ToolsSettingsInteraction + data class TTSFormat( + val value: TTSMessageFormat, + ) : ToolsSettingsInteraction - data class TTSForceEnglish(val value: Boolean) : ToolsSettingsInteraction + data class TTSForceEnglish( + val value: Boolean, + ) : ToolsSettingsInteraction - data class TTSIgnoreUrls(val value: Boolean) : ToolsSettingsInteraction + data class TTSIgnoreUrls( + val value: Boolean, + ) : ToolsSettingsInteraction - data class TTSIgnoreEmotes(val value: Boolean) : ToolsSettingsInteraction + data class TTSIgnoreEmotes( + val value: Boolean, + ) : ToolsSettingsInteraction - data class TTSUserIgnoreList(val value: Set) : ToolsSettingsInteraction + data class TTSUserIgnoreList( + val value: Set, + ) : ToolsSettingsInteraction } data class ToolsSettingsState( 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 7c6ac8f81..551bb3ec7 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 @@ -13,7 +13,10 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class ToolsSettingsViewModel(private val toolsSettingsDataStore: ToolsSettingsDataStore, private val recentUploadsRepository: RecentUploadsRepository) : ViewModel() { +class ToolsSettingsViewModel( + private val toolsSettingsDataStore: ToolsSettingsDataStore, + private val recentUploadsRepository: RecentUploadsRepository, +) : ViewModel() { val settings = combine( toolsSettingsDataStore.settings, @@ -26,29 +29,31 @@ class ToolsSettingsViewModel(private val toolsSettingsDataStore: ToolsSettingsDa 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.TTSUserIgnoreList -> toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = interaction.value) } + 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.TTSUserIgnoreList -> toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = interaction.value) } + } } } - } } -private fun ToolsSettings.toState(hasRecentUploads: Boolean) = ToolsSettingsState( - imageUploader = uploaderConfig, - hasRecentUploads = hasRecentUploads, - ttsEnabled = ttsEnabled, - ttsPlayMode = ttsPlayMode, - ttsMessageFormat = ttsMessageFormat, - ttsForceEnglish = ttsForceEnglish, - ttsIgnoreUrls = ttsIgnoreUrls, - ttsIgnoreEmotes = ttsIgnoreEmotes, - ttsUserIgnoreList = ttsUserIgnoreList.toImmutableSet(), -) +private fun ToolsSettings.toState(hasRecentUploads: Boolean) = + ToolsSettingsState( + imageUploader = uploaderConfig, + hasRecentUploads = hasRecentUploads, + ttsEnabled = ttsEnabled, + ttsPlayMode = ttsPlayMode, + ttsMessageFormat = ttsMessageFormat, + ttsForceEnglish = ttsForceEnglish, + ttsIgnoreUrls = ttsIgnoreUrls, + ttsIgnoreEmotes = ttsIgnoreEmotes, + 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 6784924b4..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 @@ -69,7 +69,11 @@ fun TTSUserIgnoreListScreen(onNavBack: () -> Unit) { } @Composable -private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSaveAndNavBack: (List) -> Unit, onSave: (List) -> Unit) { +private fun UserIgnoreListScreen( + initialIgnores: ImmutableList, + onSaveAndNavBack: (List) -> Unit, + onSave: (List) -> Unit, +) { val focusManager = LocalFocusManager.current val ignores = remember { initialIgnores.toMutableStateList() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -87,9 +91,10 @@ private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSa 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,9 +113,10 @@ private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSa 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 = "") @@ -129,10 +135,11 @@ private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSa 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 = { _, item -> item.id }) { idx, ignore -> UserIgnoreItem( @@ -143,11 +150,12 @@ private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSa val removed = ignores.removeAt(idx) scope.launch { snackbarHost.currentSnackbarData?.dismiss() - val result = snackbarHost.showSnackbar( - message = itemRemovedMsg, - actionLabel = undoMsg, - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { focusManager.clearFocus() ignores.add(idx, removed) @@ -155,12 +163,13 @@ private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSa } } }, - 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") { @@ -171,14 +180,20 @@ private fun UserIgnoreListScreen(initialIgnores: ImmutableList, onSa } @Composable -private fun UserIgnoreItem(user: String, onUserChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier) { +private fun UserIgnoreItem( + user: String, + onUserChange: (String) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { SwipeToDelete(onRemove, modifier) { ElevatedCard { Row { OutlinedTextField( - modifier = Modifier - .weight(1f) - .padding(16.dp), + modifier = + Modifier + .weight(1f) + .padding(16.dp), value = user, onValueChange = onUserChange, label = { Text(stringResource(R.string.tts_ignore_list_user_hint)) }, 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 71f0a2aaa..b7e49b229 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 @@ -14,7 +14,9 @@ import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @KoinViewModel -class TTSUserIgnoreListViewModel(private val toolsSettingsDataStore: ToolsSettingsDataStore) : ViewModel() { +class TTSUserIgnoreListViewModel( + private val toolsSettingsDataStore: ToolsSettingsDataStore, +) : ViewModel() { val userIgnores = toolsSettingsDataStore.settings .map { it.ttsUserIgnoreList.mapToUserIgnores() } @@ -24,12 +26,16 @@ class TTSUserIgnoreListViewModel(private val toolsSettingsDataStore: ToolsSettin initialValue = toolsSettingsDataStore.current().ttsUserIgnoreList.mapToUserIgnores(), ) - fun save(ignores: List) = viewModelScope.launch { - val filtered = ignores.mapNotNullTo(mutableSetOf()) { it.user.takeIf { it.isNotBlank() } } - toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = filtered) } - } + fun save(ignores: List) = + viewModelScope.launch { + val filtered = ignores.mapNotNullTo(mutableSetOf()) { it.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 00d492332..d073e5ef3 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 @@ -71,22 +71,28 @@ fun ImageUploaderScreen(onNavBack: () -> Unit) { } @Composable -private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () -> Unit, onSave: (ImageUploaderConfig) -> Unit, onSaveAndNavBack: (ImageUploaderConfig) -> Unit) { +private fun ImageUploaderScreen( + uploaderConfig: ImageUploaderConfig, + onReset: () -> Unit, + onSave: (ImageUploaderConfig) -> Unit, + onSaveAndNavBack: (ImageUploaderConfig) -> Unit, +) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val uploadUrl = rememberTextFieldState(uploaderConfig.uploadUrl) val formField = rememberTextFieldState(uploaderConfig.formField) 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 || - uploaderConfig.formField != formField.text || - uploaderConfig.headers.orEmpty() != headers.text || - uploaderConfig.imageLinkPattern.orEmpty() != linkPattern.text || - uploaderConfig.deletionLinkPattern.orEmpty() != deleteLinkPattern.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 = { @@ -107,9 +113,10 @@ private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), topBar = { TopAppBar( scrollBehavior = scrollBehavior, @@ -124,17 +131,19 @@ private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () }, ) { 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( @@ -147,11 +156,12 @@ private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () 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)) @@ -159,10 +169,11 @@ private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () 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)) @@ -170,10 +181,11 @@ private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () 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)) @@ -181,10 +193,11 @@ private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () 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)) @@ -192,18 +205,20 @@ private fun ImageUploaderScreen(uploaderConfig: ImageUploaderConfig, onReset: () 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)) }, ) 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 bc359a167..3425c55e3 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 @@ -12,7 +12,9 @@ import org.koin.android.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class ImageUploaderViewModel(private val toolsSettingsDataStore: ToolsSettingsDataStore) : ViewModel() { +class ImageUploaderViewModel( + private val toolsSettingsDataStore: ToolsSettingsDataStore, +) : ViewModel() { val uploader = toolsSettingsDataStore.uploadConfig .stateIn( @@ -21,17 +23,19 @@ class ImageUploaderViewModel(private val toolsSettingsDataStore: ToolsSettingsDa 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() }, - ) - toolsSettingsDataStore.update { it.copy(uploaderConfig = validated) } - } + 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() }, + ) + toolsSettingsDataStore.update { it.copy(uploaderConfig = validated) } + } - fun reset() = viewModelScope.launch { - toolsSettingsDataStore.update { it.copy(uploaderConfig = ImageUploaderConfig.DEFAULT) } - } + fun reset() = + viewModelScope.launch { + toolsSettingsDataStore.update { it.copy(uploaderConfig = ImageUploaderConfig.DEFAULT) } + } } 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 d10bfaa89..ecaf2c2d6 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 @@ -17,7 +17,9 @@ import java.util.Locale import kotlin.time.Duration.Companion.seconds @KoinViewModel -class RecentUploadsViewModel(private val recentUploadsRepository: RecentUploadsRepository) : ViewModel() { +class RecentUploadsViewModel( + private val recentUploadsRepository: RecentUploadsRepository, +) : ViewModel() { val recentUploads = recentUploadsRepository .getRecentUploads() @@ -36,9 +38,10 @@ class RecentUploadsViewModel(private val recentUploadsRepository: RecentUploadsR initialValue = emptyList(), ) - fun clearUploads() = viewModelScope.launch { - recentUploadsRepository.clearUploads() - } + fun clearUploads() = + viewModelScope.launch { + recentUploadsRepository.clearUploads() + } companion object { private val formatter = @@ -46,8 +49,9 @@ class RecentUploadsViewModel(private val recentUploadsRepository: RecentUploadsR .ofLocalizedDateTime(FormatStyle.SHORT) .withZone(ZoneId.systemDefault()) - private fun Instant.formatWithLocale(locale: Locale) = formatter - .withLocale(locale) - .format(this) + private fun Instant.formatWithLocale(locale: Locale) = + formatter + .withLocale(locale) + .format(this) } } 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 index 2d224c827..57d3dc6fc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt @@ -59,9 +59,10 @@ fun ChangelogScreen(onBack: () -> Unit) { ) { padding -> val entries = state.changelog.split("\n").filter { it.isNotBlank() } LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding), + modifier = + Modifier + .fillMaxSize() + .padding(padding), ) { items(entries) { entry -> Text( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt index c8297eb00..411316ed6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt @@ -5,7 +5,9 @@ import com.flxrs.dankchat.preferences.DankChatPreferenceStore import org.koin.android.annotation.KoinViewModel @KoinViewModel -class ChangelogSheetViewModel(dankChatPreferenceStore: DankChatPreferenceStore) : ViewModel() { +class ChangelogSheetViewModel( + dankChatPreferenceStore: DankChatPreferenceStore, +) : ViewModel() { init { dankChatPreferenceStore.setCurrentInstalledVersionCode() } 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 index c4c30d778..932d093dd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt @@ -3,4 +3,7 @@ package com.flxrs.dankchat.ui.changelog import androidx.compose.runtime.Immutable @Immutable -data class ChangelogState(val version: String, val changelog: String) +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 index 9c720ccca..e5e2da497 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatChangelog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatChangelog.kt @@ -1,4 +1,7 @@ package com.flxrs.dankchat.ui.changelog @Suppress("unused") -enum class DankChatChangelog(val version: DankChatVersion, val string: String) +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 index e2071d31e..b5c1a9fcf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt @@ -2,7 +2,11 @@ package com.flxrs.dankchat.ui.changelog import com.flxrs.dankchat.BuildConfig -data class DankChatVersion(val major: Int, val minor: Int, val patch: Int) : Comparable { +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" @@ -15,11 +19,12 @@ data class DankChatVersion(val major: Int, val minor: Int, val patch: Int) : Com .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) } + 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/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt index 6c1702334..4e9fc7e1f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt @@ -52,7 +52,10 @@ fun rememberAdaptiveTextColor(backgroundColor: Color): Color { * to produce an opaque color for accurate contrast calculation. */ @Composable -fun rememberNormalizedColor(rawColor: Int, backgroundColor: Color): Color { +fun rememberNormalizedColor( + rawColor: Int, + backgroundColor: Color, +): Color { val effectiveBg = resolveEffectiveBackground(backgroundColor) val effectiveBgArgb = effectiveBg.toArgb() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt index bcbac195d..b29b2c609 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt @@ -14,7 +14,10 @@ import com.google.android.material.color.MaterialColors * theme background to produce an opaque result suitable for contrast calculations. */ @Composable -fun rememberBackgroundColor(lightColor: Color, darkColor: Color): Color { +fun rememberBackgroundColor( + lightColor: Color, + darkColor: Color, +): Color { val raw = if (isSystemInDarkTheme()) darkColor else lightColor val background = MaterialTheme.colorScheme.background return remember(raw, background) { 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 index eaa7f903e..e79e3ab6e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -50,10 +50,11 @@ fun ChatComposable( onTourSkip: (() -> Unit)? = null, ) { // Create ChatViewModel with channel-specific key for proper scoping - val viewModel: ChatViewModel = koinViewModel( - key = channel.value, - parameters = { parametersOf(channel) }, - ) + val viewModel: ChatViewModel = + koinViewModel( + key = channel.value, + parameters = { parametersOf(channel) }, + ) val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val displaySettings by viewModel.chatDisplaySettings.collectAsStateWithLifecycle() @@ -66,14 +67,15 @@ fun ChatComposable( 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) }, - ), + 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) }, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = modifier.fillMaxSize(), 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 index 0e1a0a2ac..3d5513e48 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -38,8 +38,15 @@ import org.koin.core.annotation.Single * 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 { +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 @@ -124,7 +131,12 @@ class ChatMessageMapper(private val usersRepository: UsersRepository) { } } - private fun SystemMessage.toSystemMessageUi(tag: Int, chatSettings: ChatSettings, isAlternateBackground: Boolean, textAlpha: Float): ChatMessageUiState.SystemMessageUi { + private fun SystemMessage.toSystemMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.SystemMessageUi { val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) val timestamp = if (chatSettings.showTimestamps) { @@ -274,7 +286,12 @@ class ChatMessageMapper(private val usersRepository: UsersRepository) { ) } - private fun NoticeMessage.toNoticeMessageUi(tag: Int, chatSettings: ChatSettings, isAlternateBackground: Boolean, textAlpha: Float): ChatMessageUiState.NoticeMessageUi { + private fun NoticeMessage.toNoticeMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.NoticeMessageUi { val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) val timestamp = if (chatSettings.showTimestamps) { @@ -294,7 +311,12 @@ class ChatMessageMapper(private val usersRepository: UsersRepository) { ) } - private fun UserNoticeMessage.toUserNoticeMessageUi(tag: Int, chatSettings: ChatSettings, isAlternateBackground: Boolean, textAlpha: Float): ChatMessageUiState.UserNoticeMessageUi { + private fun UserNoticeMessage.toUserNoticeMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.UserNoticeMessageUi { val shouldHighlight = highlights.any { it.type == HighlightType.Subscription || @@ -372,7 +394,11 @@ class ChatMessageMapper(private val usersRepository: UsersRepository) { ) } - private fun AutomodMessage.toAutomodMessageUi(tag: Int, chatSettings: ChatSettings, textAlpha: Float): ChatMessageUiState.AutomodMessageUi { + private fun AutomodMessage.toAutomodMessageUi( + tag: Int, + chatSettings: ChatSettings, + textAlpha: Float, + ): ChatMessageUiState.AutomodMessageUi { val timestamp = if (chatSettings.showTimestamps) { DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) @@ -398,19 +424,19 @@ class ChatMessageMapper(private val usersRepository: UsersRepository) { 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(), + 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, messageText = messageText?.takeIf { it.isNotEmpty() }, @@ -557,7 +583,11 @@ class ChatMessageMapper(private val usersRepository: UsersRepository) { ) } - private fun PointRedemptionMessage.toPointRedemptionMessageUi(tag: Int, chatSettings: ChatSettings, textAlpha: Float): ChatMessageUiState.PointRedemptionMessageUi { + private fun PointRedemptionMessage.toPointRedemptionMessageUi( + tag: Int, + chatSettings: ChatSettings, + textAlpha: Float, + ): ChatMessageUiState.PointRedemptionMessageUi { val backgroundColors = getHighlightColors(HighlightType.ChannelPointRedemption) val timestamp = if (chatSettings.showTimestamps) { @@ -683,57 +713,65 @@ class ChatMessageMapper(private val usersRepository: UsersRepository) { ) } - 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) - } + data class BackgroundColors( + val light: Color, + val dark: Color, + ) - private fun getHighlightColors(type: HighlightType): BackgroundColors = when (type) { - HighlightType.Subscription, - HighlightType.Announcement, - -> { - BackgroundColors( - light = COLOR_SUB_HIGHLIGHT_LIGHT, - dark = COLOR_SUB_HIGHLIGHT_DARK, - ) + private fun calculateCheckeredBackgroundColors( + isAlternateBackground: Boolean, + enableCheckered: Boolean, + ): BackgroundColors = + if (enableCheckered && isAlternateBackground) { + BackgroundColors(CHECKERED_LIGHT, CHECKERED_DARK) + } else { + BackgroundColors(Color.Transparent, Color.Transparent) } - HighlightType.ChannelPointRedemption -> { - BackgroundColors( - light = COLOR_REDEMPTION_HIGHLIGHT_LIGHT, - dark = COLOR_REDEMPTION_HIGHLIGHT_DARK, - ) - } + private fun getHighlightColors(type: HighlightType): BackgroundColors = + when (type) { + HighlightType.Subscription, + HighlightType.Announcement, + -> { + BackgroundColors( + light = COLOR_SUB_HIGHLIGHT_LIGHT, + dark = COLOR_SUB_HIGHLIGHT_DARK, + ) + } - HighlightType.ElevatedMessage -> { - BackgroundColors( - light = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT, - dark = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK, - ) - } + HighlightType.ChannelPointRedemption -> { + BackgroundColors( + light = COLOR_REDEMPTION_HIGHLIGHT_LIGHT, + dark = COLOR_REDEMPTION_HIGHLIGHT_DARK, + ) + } - HighlightType.FirstMessage -> { - BackgroundColors( - light = COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT, - dark = COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK, - ) - } + HighlightType.ElevatedMessage -> { + BackgroundColors( + light = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT, + dark = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK, + ) + } - HighlightType.Username, - HighlightType.Custom, - HighlightType.Reply, - HighlightType.Badge, - HighlightType.Notification, - -> { - BackgroundColors( - light = COLOR_MENTION_HIGHLIGHT_LIGHT, - dark = COLOR_MENTION_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 = diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt index 0677cf2c9..f443b5617 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt @@ -43,51 +43,52 @@ fun ChatMessageText( val defaultTextColor = textColor ?: MaterialTheme.colorScheme.onSurface val defaultNameColor = nameColor ?: MaterialTheme.colorScheme.onSurface - val annotatedString = remember(text, timestamp, nameText, defaultNameColor, isAction, defaultTextColor, timestampColor, fontSize) { - buildAnnotatedString { - // Add timestamp if present - if (timestamp != null) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = fontSize * 0.95f, - color = timestampColor, - letterSpacing = (-0.03).em, - ), - ) { - append(timestamp) + val annotatedString = + remember(text, timestamp, nameText, defaultNameColor, isAction, defaultTextColor, timestampColor, fontSize) { + buildAnnotatedString { + // Add timestamp if present + if (timestamp != null) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = fontSize * 0.95f, + color = timestampColor, + letterSpacing = (-0.03).em, + ), + ) { + append(timestamp) + } + append(" ") } - append(" ") - } - // Add username if present - if (nameText != null) { + // Add username if present + if (nameText != null) { + withStyle( + SpanStyle( + color = defaultNameColor, + fontWeight = FontWeight.Bold, + ), + ) { + append(nameText) + } + if (!isAction) { + append(": ") + } else { + append(" ") + } + } + + // Add message text withStyle( SpanStyle( - color = defaultNameColor, - fontWeight = FontWeight.Bold, + color = if (isAction) defaultNameColor else defaultTextColor, ), ) { - append(nameText) - } - if (!isAction) { - append(": ") - } else { - append(" ") + append(text) } } - - // Add message text - withStyle( - SpanStyle( - color = if (isAction) defaultNameColor else defaultTextColor, - ), - ) { - append(text) - } } - } Box(modifier = modifier) { BasicText( 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 index 5aa41b794..d45317622 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -249,13 +249,18 @@ data class EmoteUi( * UI state for reply threads */ @Immutable -data class ThreadUi(val rootId: String, val userName: String, val message: String) +data class ThreadUi( + val rootId: String, + val userName: String, + val message: String, +) /** * Converts MessageThreadHeader to ThreadUi */ -fun MessageThreadHeader.toThreadUi(): ThreadUi = ThreadUi( - rootId = rootId, - userName = name.value, - message = message, -) +fun MessageThreadHeader.toThreadUi(): ThreadUi = + ThreadUi( + rootId = rootId, + userName = name.value, + message = message, + ) 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 index bf3548baf..4ce2a5de5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -157,9 +157,10 @@ fun ChatScreen( state = listState, reverseLayout = true, contentPadding = contentPadding, - modifier = Modifier - .fillMaxSize() - .then(scrollModifier), + modifier = + Modifier + .fillMaxSize() + .then(scrollModifier), ) { itemsIndexed( items = reversedMessages, @@ -213,9 +214,10 @@ fun ChatScreen( ) Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + fabBottomPadding), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + fabBottomPadding), contentAlignment = Alignment.BottomEnd, ) { if (recoveryFabTooltipState != null) { @@ -272,7 +274,12 @@ fun ChatScreen( } @Composable -private fun RecoveryFab(isFullscreen: Boolean, showInput: Boolean, onRecover: () -> Unit, modifier: Modifier = Modifier) { +private fun RecoveryFab( + isFullscreen: Boolean, + showInput: Boolean, + onRecover: () -> Unit, + modifier: Modifier = Modifier, +) { val visible = isFullscreen || !showInput AnimatedVisibility( visible = visible, @@ -295,7 +302,11 @@ private fun RecoveryFab(isFullscreen: Boolean, showInput: Boolean, onRecover: () private val HIGHLIGHT_CORNER_RADIUS = 8.dp -private fun ChatMessageUiState.highlightShape(highlightedAbove: Boolean, highlightedBelow: Boolean, showLineSeparator: Boolean): Shape { +private fun ChatMessageUiState.highlightShape( + highlightedAbove: Boolean, + highlightedBelow: Boolean, + showLineSeparator: Boolean, +): Shape { if (!isHighlighted) return RectangleShape if (showLineSeparator) return RectangleShape val top = if (highlightedAbove) 0.dp else HIGHLIGHT_CORNER_RADIUS @@ -308,72 +319,97 @@ private fun ChatMessageUiState.highlightShape(highlightedAbove: Boolean, highlig */ @Composable -private fun ChatMessageItem(message: ChatMessageUiState, highlightShape: Shape, fontSize: Float, showChannelPrefix: Boolean, animateGifs: Boolean, callbacks: ChatScreenCallbacks) { +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.SystemMessageUi -> { + SystemMessageComposable( + message = message, + fontSize = fontSize, + ) + } - is ChatMessageUiState.NoticeMessageUi -> NoticeMessageComposable( - message = message, - fontSize = fontSize, - ) + is ChatMessageUiState.NoticeMessageUi -> { + NoticeMessageComposable( + message = message, + fontSize = fontSize, + ) + } - is ChatMessageUiState.UserNoticeMessageUi -> UserNoticeMessageComposable( - message = message, - highlightShape = highlightShape, - fontSize = fontSize, - ) + is ChatMessageUiState.UserNoticeMessageUi -> { + UserNoticeMessageComposable( + message = message, + highlightShape = highlightShape, + fontSize = fontSize, + ) + } - is ChatMessageUiState.ModerationMessageUi -> ModerationMessageComposable( - message = message, - fontSize = fontSize, - ) + is ChatMessageUiState.ModerationMessageUi -> { + ModerationMessageComposable( + message = message, + fontSize = fontSize, + ) + } - is ChatMessageUiState.AutomodMessageUi -> AutomodMessageComposable( - message = message, - fontSize = fontSize, - onAllow = callbacks.onAutomodAllow, - onDeny = callbacks.onAutomodDeny, - ) + 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.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.PointRedemptionMessageUi -> { + PointRedemptionMessageComposable( + message = message, + highlightShape = highlightShape, + fontSize = fontSize, + ) + } - is ChatMessageUiState.DateSeparatorUi -> DateSeparatorComposable( - message = message, - 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, - ) + 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, + ) + } } } @@ -386,7 +422,11 @@ private fun ChatMessageItem(message: ChatMessageUiState, highlightShape: Shape, * 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) { +private suspend fun LazyListState.scrollToCentered( + index: Int, + topPaddingPx: Int, + bottomPaddingPx: Int, +) { scrollToItem(index) val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return 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 index e9c29a8db..ace92b4c5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt @@ -20,10 +20,19 @@ import androidx.compose.ui.input.pointer.positionChange * * 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 { +class ScrollDirectionTracker( + private val hideThresholdPx: Float, + private val showThresholdPx: Float, + private val onHide: () -> Unit, + private val onShow: () -> Unit, +) : NestedScrollConnection { private var accumulated = 0f - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + 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 @@ -53,7 +62,11 @@ class ScrollDirectionTracker(private val hideThresholdPx: Float, private val sho * 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 { +fun Modifier.swipeDownToHide( + enabled: Boolean, + thresholdPx: Float, + onHide: () -> Unit, +): Modifier { if (!enabled) return this return this.pointerInput(enabled) { awaitEachGesture { 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 index 3f8f33700..61f02173f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -149,7 +149,11 @@ class ChatViewModel( }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), persistentListOf()) - fun manageAutomodMessage(heldMessageId: String, channel: UserName, allow: Boolean) { + fun manageAutomodMessage( + heldMessageId: String, + channel: UserName, + allow: Boolean, + ) { viewModelScope.launch { val userId = authDataStore.userIdString ?: return@launch val action = if (allow) "ALLOW" else "DENY" @@ -173,4 +177,8 @@ class ChatViewModel( } @Immutable -data class ChatDisplaySettings(val fontSize: Float = 14f, val showLineSeparator: Boolean = false, val animateGifs: Boolean = true) +data class ChatDisplaySettings( + val fontSize: Float = 14f, + val showLineSeparator: Boolean = false, + val animateGifs: Boolean = true, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt index c5604ed07..ad53e9f16 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt @@ -31,7 +31,10 @@ import com.flxrs.dankchat.utils.extensions.setRunning * - EmoteRepository.layerCache: LruCache(256) */ @Stable -class EmoteAnimationCoordinator(val imageLoader: ImageLoader, private val platformContext: PlatformContext) { +class EmoteAnimationCoordinator( + val imageLoader: ImageLoader, + private val platformContext: PlatformContext, +) { // LruCache for single emote drawables (like badgeCache in EmoteRepository) private val emoteCache = LruCache(256) @@ -47,7 +50,10 @@ class EmoteAnimationCoordinator(val imageLoader: ImageLoader, private val platfo * Returns cached drawable if available, otherwise loads and caches it. * Sharing the same Drawable instance keeps animations synchronized. */ - suspend fun getOrLoadEmote(url: String, animateGifs: Boolean): Drawable? { + suspend fun getOrLoadEmote( + url: String, + animateGifs: Boolean, + ): Drawable? { // Fast path: already cached emoteCache.get(url)?.let { cached -> // Control animation based on setting @@ -96,7 +102,10 @@ class EmoteAnimationCoordinator(val imageLoader: ImageLoader, private val platfo /** * Put a drawable in the cache (used by AsyncImage onSuccess callback). */ - fun putInCache(url: String, drawable: Drawable) { + fun putInCache( + url: String, + drawable: Drawable, + ) { emoteCache.put(url, drawable) } @@ -108,7 +117,10 @@ class EmoteAnimationCoordinator(val imageLoader: ImageLoader, private val platfo /** * Put a LayerDrawable in the cache for stacked emotes. */ - fun putLayerInCache(cacheKey: String, layerDrawable: LayerDrawable) { + fun putLayerInCache( + cacheKey: String, + layerDrawable: LayerDrawable, + ) { layerCache.put(cacheKey, layerDrawable) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt index d092290c8..7392135c3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt @@ -20,8 +20,9 @@ import androidx.compose.ui.unit.LayoutDirection * the callback chain so animations continue after scrolling off/on screen. */ @Stable -class EmoteDrawablePainter(val drawable: Drawable) : - Painter(), +class EmoteDrawablePainter( + val drawable: Drawable, +) : Painter(), androidx.compose.runtime.RememberObserver { private var invalidateTick by mutableIntStateOf(0) @@ -33,11 +34,18 @@ class EmoteDrawablePainter(val drawable: Drawable) : invalidateTick++ } - override fun scheduleDrawable(d: Drawable, what: Runnable, time: Long) { + override fun scheduleDrawable( + d: Drawable, + what: Runnable, + time: Long, + ) { mainHandler.postAtTime(what, time) } - override fun unscheduleDrawable(d: Drawable, what: Runnable) { + override fun unscheduleDrawable( + d: Drawable, + what: Runnable, + ) { mainHandler.removeCallbacks(what) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt index 25d8cf681..e2bc58b8a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt @@ -49,7 +49,12 @@ object EmoteScaling { * @param baseHeightPx Base height in pixels (line height) * @return Pair of (widthPx, heightPx) in pixels */ - fun calculateEmoteDimensionsPx(intrinsicWidth: Int, intrinsicHeight: Int, emote: ChatMessageEmote, baseHeightPx: Int): Pair { + fun calculateEmoteDimensionsPx( + intrinsicWidth: Int, + intrinsicHeight: Int, + emote: ChatMessageEmote, + baseHeightPx: Int, + ): Pair { val scale = baseHeightPx * SCALE_FACTOR_CONSTANT val ratio = intrinsicWidth / intrinsicHeight.toFloat() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt index e744ef36e..17138fa30 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt @@ -9,7 +9,11 @@ import androidx.compose.ui.text.withStyle private val DISALLOWED_URL_CHARS = """<>\{}|^"`""".toSet() -fun AnnotatedString.Builder.appendWithLinks(text: String, linkColor: Color, previousChar: Char? = null) { +fun AnnotatedString.Builder.appendWithLinks( + text: String, + linkColor: Color, + previousChar: Char? = null, +) { val matcher = Patterns.WEB_URL.matcher(text) var lastIndex = 0 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt index ecfa1c121..78c06e6ed 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt @@ -72,46 +72,51 @@ fun StackedEmote( 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()) { - // Create LayerDrawable exactly like old implementation - 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 + val layerDrawableState = + produceState(initialValue = null, key1 = cacheKey) { + // Check cache first + val cached = emoteCoordinator.getLayerCached(cacheKey) + if (cached != null) { + value = cached // Control animation - layerDrawable.forEachLayer { it.setRunning(animateGifs) } + 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()) { + // Create LayerDrawable exactly like old implementation + 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) { @@ -129,18 +134,20 @@ fun StackedEmote( painter = painter, contentDescription = null, alpha = alpha, - modifier = modifier - .size(width = widthDp, height = heightDp) - .clickable { onClick() }, + 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() }, + modifier = + modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() }, ) } } @@ -166,34 +173,37 @@ private fun SingleEmoteDrawable( 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 + 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 } - } catch (_: Exception) { - // Ignore errors } } - } // Update animation state when setting changes LaunchedEffect(animateGifs, drawableState.value) { @@ -213,18 +223,20 @@ private fun SingleEmoteDrawable( painter = painter, contentDescription = null, alpha = alpha, - modifier = modifier - .size(width = widthDp, height = heightDp) - .clickable { onClick() }, + 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() }, + modifier = + modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() }, ) } } @@ -233,13 +245,20 @@ private fun SingleEmoteDrawable( * Transform emote drawable exactly like old ChatAdapter.transformEmoteDrawable(). * Phase 1: Individual scaling without maxWidth/maxHeight. */ -private fun transformEmoteDrawable(drawable: Drawable, scale: Double, emote: ChatMessageEmote, maxWidth: Int = 0, maxHeight: Int = 0): Drawable { +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 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 @@ -255,14 +274,18 @@ private fun transformEmoteDrawable(drawable: Drawable, scale: Double, emote: Cha /** * Create LayerDrawable from array of drawables exactly like old ChatAdapter.toLayerDrawable(). */ -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) +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) + // 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/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt index 4a43933b6..eb88fed29 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt @@ -26,7 +26,11 @@ import kotlinx.coroutines.launch /** * Data class to hold measured emote dimensions */ -data class EmoteDimensions(val id: String, val widthPx: Int, val heightPx: Int) +data class EmoteDimensions( + val id: String, + val widthPx: Int, + val heightPx: Int, +) /** * Renders text with inline images (badges, emotes) using SubcomposeLayout. @@ -79,88 +83,94 @@ fun TextWithMeasuredInlineContent( val measurables = subcompose("measure_$id", provider) if (measurables.isNotEmpty()) { // Measure with unbounded constraints to get natural size - val placeable = measurables.first().measure( - Constraints( - maxWidth = constraints.maxWidth, - maxHeight = Constraints.Infinity, - ), - ) - measuredDimensions[id] = EmoteDimensions( - id = id, - widthPx = placeable.width, - heightPx = placeable.height, - ) + val placeable = + measurables.first().measure( + Constraints( + maxWidth = constraints.maxWidth, + maxHeight = Constraints.Infinity, + ), + ) + measuredDimensions[id] = + EmoteDimensions( + id = id, + widthPx = placeable.width, + heightPx = placeable.height, + ) } } } // Phase 2: Create InlineTextContent with measured/known dimensions - val inlineContent = measuredDimensions.mapValues { (id, dimensions) -> - InlineTextContent( - placeholder = Placeholder( - width = with(density) { dimensions.widthPx.toDp() }.value.sp, - height = with(density) { dimensions.heightPx.toDp() }.value.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, - ), - ) { - // Render the actual content (re-compose with same provider) - inlineContentProviders[id]?.invoke() + val inlineContent = + measuredDimensions.mapValues { (id, dimensions) -> + InlineTextContent( + placeholder = + Placeholder( + width = with(density) { dimensions.widthPx.toDp() }.value.sp, + height = with(density) { dimensions.heightPx.toDp() }.value.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + // Render the actual content (re-compose with same provider) + inlineContentProviders[id]?.invoke() + } } - } // Phase 3: Compose the text with correct inline content - val textMeasurables = subcompose("text") { - BasicText( - text = text, - style = style, - inlineContent = inlineContent, - modifier = Modifier.pointerInput(text, interactionSource) { - detectTapGestures( - onPress = { offset -> - // Emit press interaction for ripple effect - 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) { - val position = layoutResult.getOffsetForPosition(offset) - onTextClick?.invoke(position) - } - } - }, - 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) - } + val textMeasurables = + subcompose("text") { + BasicText( + text = text, + style = style, + inlineContent = inlineContent, + modifier = + Modifier.pointerInput(text, interactionSource) { + detectTapGestures( + onPress = { offset -> + // Emit press interaction for ripple effect + 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) { + val position = layoutResult.getOffsetForPosition(offset) + onTextClick?.invoke(position) + } + } + }, + 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 - }, - ) - } + onTextLayout = { layoutResult -> + textLayoutResultRef.value = layoutResult + }, + ) + } if (textMeasurables.isEmpty()) { return@SubcomposeLayout layout(0, 0) {} @@ -180,7 +190,11 @@ fun TextWithMeasuredInlineContent( * Use this when you already have the dimensions or don't need click handling. */ @Composable -fun MeasuredInlineText(text: AnnotatedString, inlineContent: Map, modifier: Modifier = Modifier) { +fun MeasuredInlineText( + text: AnnotatedString, + inlineContent: Map, + modifier: Modifier = Modifier, +) { Box(modifier = modifier) { BasicText( text = text, 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 index 25c026372..c6faba21c 100644 --- 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 @@ -9,7 +9,9 @@ import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @KoinViewModel -class EmoteInfoViewModel(@InjectedParam private val emotes: List) : ViewModel() { +class EmoteInfoViewModel( + @InjectedParam private val emotes: List, +) : ViewModel() { val items = emotes.map { emote -> EmoteSheetItem( @@ -24,49 +26,53 @@ class EmoteInfoViewModel(@InjectedParam private val emotes: List type.baseName - is ChatMessageEmoteType.ChannelSevenTVEmote -> type.baseName - else -> null - } + 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.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" + 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.ChannelBTTVEmote, + is ChatMessageEmoteType.GlobalBTTVEmote, + -> "$BTTV_BASE_LINK$id" - is ChatMessageEmoteType.ChannelFFZEmote, - is ChatMessageEmoteType.GlobalFFZEmote, - -> "$FFZ_BASE_LINK$id-$code" + is ChatMessageEmoteType.ChannelFFZEmote, + is ChatMessageEmoteType.GlobalFFZEmote, + -> "$FFZ_BASE_LINK$id-$code" - is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" + is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" - is ChatMessageEmoteType.Cheermote -> "$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 - } + 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/" 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 index 45dfb5b2b..bf12108b3 100644 --- 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 @@ -5,16 +5,20 @@ import com.flxrs.dankchat.data.twitch.emote.GenericEmote @Immutable sealed class EmoteItem { - data class Emote(val emote: GenericEmote) : - 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 - } + 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() + data class Header( + val title: String, + ) : EmoteItem() override fun equals(other: Any?): Boolean { if (this === other) return true 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 index fc32cb509..6c16e68ca 100644 --- 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 @@ -3,4 +3,7 @@ package com.flxrs.dankchat.ui.chat.emotemenu import androidx.compose.runtime.Immutable @Immutable -data class EmoteMenuTabItem(val type: EmoteMenuTab, val items: List) +data class EmoteMenuTabItem( + val type: EmoteMenuTab, + val items: List, +) 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 index ce97b3c12..e8590585e 100644 --- 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 @@ -53,12 +53,13 @@ fun MentionComposable( ChatScreen( messages = messages, fontSize = displaySettings.fontSize, - callbacks = ChatScreenCallbacks( - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onWhisperReply = if (isWhisperTab) onWhisperReply else null, - ), + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onWhisperReply = if (isWhisperTab) onWhisperReply else null, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, showChannelPrefix = !isWhisperTab, 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 index 018a8f476..c56865899 100644 --- 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 @@ -76,25 +76,29 @@ class MessageOptionsViewModel( } }.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 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 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 unbanUser() = + viewModelScope.launch { + val name = (state.value as? MessageOptionsState.Found)?.name ?: return@launch + sendCommand(".unban $name") + } - fun deleteMessage() = viewModelScope.launch { - sendCommand(".delete $messageId") - } + fun deleteMessage() = + viewModelScope.launch { + sendCommand(".delete $messageId") + } private suspend fun sendCommand(message: String) { val activeChannel = channel ?: return 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 index 3e3ff3f4b..0c873fb7d 100644 --- 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 @@ -67,159 +67,179 @@ fun AutomodMessageComposable( 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) - append(" ") + 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) + append(" ") + } } - } - - // Badges - message.badges.forEach { badge -> - appendInlineContent("BADGE_${badge.position}", "[badge]") - append(" ") - } - // "AutoMod: " in blue bold - withStyle(SpanStyle(color = AutoModBlue, fontWeight = FontWeight.Bold)) { - append("AutoMod: ") - } + // Badges + message.badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") + } - 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) } + // "AutoMod: " in blue bold + withStyle(SpanStyle(color = AutoModBlue, fontWeight = FontWeight.Bold)) { + append("AutoMod: ") } - // Mod-side: reason text + Allow/Deny buttons or status - else -> { - withStyle(SpanStyle(color = textColor)) { - append("$headerText ") + 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) } + } } - when (message.status) { - AutomodMessageStatus.Pending -> { - pushStringAnnotation(tag = ALLOW_TAG, annotation = message.heldMessageId) - withStyle(SpanStyle(color = allowColor, fontWeight = FontWeight.Bold)) { - append(allowText) - } - pop() + // Mod-side: reason text + Allow/Deny buttons or status + else -> { + withStyle(SpanStyle(color = textColor)) { + append("$headerText ") + } - pushStringAnnotation(tag = DENY_TAG, annotation = message.heldMessageId) - withStyle(SpanStyle(color = denyColor, fontWeight = FontWeight.Bold)) { - append(" $denyText") + 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() } - pop() - } - AutomodMessageStatus.Approved -> { - withStyle(SpanStyle(color = allowColor.copy(alpha = 0.6f), fontWeight = FontWeight.Bold)) { - append(approvedText) + 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.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) + 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) - append(" ") + 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) + append(" ") + } } - } - // Username in bold with user color - withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { - append("${message.userDisplayName}: ") - } + // Username in bold with user color + withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { + append("${message.userDisplayName}: ") + } - // Message text - withStyle(SpanStyle(color = textColor)) { - append(text) + // Message text + withStyle(SpanStyle(color = textColor)) { + append(text) + } } } } - } // Badge inline content providers (same pattern as PrivMessage) val badgeSize = EmoteScaling.getBadgeSize(fontSize) - val inlineContentProviders: Map Unit> = remember(message.badges, fontSize) { - buildMap { - message.badges.forEach { badge -> - put("BADGE_${badge.position}") { - BadgeInlineContent(badge = badge, size = badgeSize) + val inlineContentProviders: Map Unit> = + remember(message.badges, fontSize) { + buildMap { + message.badges.forEach { badge -> + put("BADGE_${badge.position}") { + BadgeInlineContent(badge = badge, size = badgeSize) + } } } } - } 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)) + 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)) + } } } - } - val resolvedAlpha = when { - message.isUserSide -> 1f - message.status == AutomodMessageStatus.Pending -> 1f - else -> 0.5f - } + val resolvedAlpha = + when { + message.isUserSide -> 1f + message.status == AutomodMessageStatus.Pending -> 1f + else -> 0.5f + } Column( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .alpha(resolvedAlpha) - .padding(horizontal = 2.dp, vertical = 2.dp), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(resolvedAlpha) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { // Header line with badge inline content TextWithMeasuredInlineContent( @@ -230,12 +250,16 @@ fun AutomodMessageComposable( modifier = Modifier.fillMaxWidth(), onTextClick = { offset -> if (isPending) { - headerString.getStringAnnotations(ALLOW_TAG, offset, offset) - .firstOrNull()?.let { + headerString + .getStringAnnotations(ALLOW_TAG, offset, offset) + .firstOrNull() + ?.let { onAllow(message.heldMessageId, message.channel) } - headerString.getStringAnnotations(DENY_TAG, offset, offset) - .firstOrNull()?.let { + headerString + .getStringAnnotations(DENY_TAG, offset, offset) + .firstOrNull() + ?.let { onDeny(message.heldMessageId, message.channel) } } 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 index 97d34a75d..5111bebdb 100644 --- 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 @@ -75,20 +75,22 @@ fun PrivMessageComposable( val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) Column( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .alpha(message.textAlpha) - .background(backgroundColor, highlightShape) - .indication(interactionSource, ripple()) - .padding(horizontal = 2.dp, vertical = 2.dp), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(backgroundColor, highlightShape) + .indication(interactionSource, ripple()) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { // Highlight type header (First Time Chat, Elevated Chat) if (message.highlightHeader != null) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { val headerColor = rememberAdaptiveTextColor(backgroundColor).copy(alpha = 0.6f) @@ -112,10 +114,11 @@ fun PrivMessageComposable( // Reply thread header if (message.thread != null) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onReplyClick(message.thread.rootId, message.thread.userName.toUserName()) } - .padding(top = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { onReplyClick(message.thread.rootId, message.thread.userName.toUserName()) } + .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { val replyColor = rememberAdaptiveTextColor(backgroundColor).copy(alpha = 0.6f) @@ -169,101 +172,103 @@ private fun PrivMessageText( val linkColor = MaterialTheme.colorScheme.primary // 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} ") + 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) - append(" ") + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { + append(message.timestamp) + append(" ") + } } - } - - // 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 ?: ""}|${message.userName.value}|${message.displayName.value}|${message.channel.value}", - ) - append(message.nameText) - pop() + // Badges (using appendInlineContent for proper rendering) + message.badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") // Space between badges } - } - // Message text with emotes - val textColor = if (message.isAction) { - nameColor - } else { - defaultTextColor - } + // 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 ?: ""}|${message.userName.value}|${message.displayName.value}|${message.channel.value}", + ) + append(message.nameText) + pop() + } + } - 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) + // Message text with emotes + val textColor = + if (message.isAction) { + nameColor + } else { + defaultTextColor } - // Emote inline content - appendInlineContent("EMOTE_${emote.code}", emote.code) + 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()) + // 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(" ") - } + // 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 - } + 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) + // 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, @@ -274,21 +279,28 @@ private fun PrivMessageText( interactionSource = interactionSource, onEmoteClick = onEmoteClick, onTextClick = { offset -> - annotatedString.getStringAnnotations("USER", offset, offset) - .firstOrNull()?.let { annotation -> + annotatedString + .getStringAnnotations("USER", offset, offset) + .firstOrNull() + ?.let { annotation -> parseUserAnnotation(annotation.item)?.let { user -> onUserClick(user.userId, user.userName, user.displayName, user.channel.orEmpty(), message.badges, false) } } - annotatedString.getStringAnnotations("URL", offset, offset) - .firstOrNull()?.let { annotation -> + annotatedString + .getStringAnnotations("URL", offset, offset) + .firstOrNull() + ?.let { annotation -> launchCustomTab(context, annotation.item) } }, onTextLongClick = { offset -> - val user = annotatedString.getStringAnnotations("USER", offset, offset) - .firstOrNull()?.let { parseUserAnnotation(it.item) } + val user = + annotatedString + .getStringAnnotations("USER", offset, offset) + .firstOrNull() + ?.let { parseUserAnnotation(it.item) } when { user != null -> onUserClick(user.userId, user.userName, user.displayName, user.channel.orEmpty(), message.badges, true) 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 index 435857a93..c7498c4d4 100644 --- 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 @@ -37,7 +37,11 @@ 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) { +fun SystemMessageComposable( + message: ChatMessageUiState.SystemMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { SimpleMessageContainer( message = message.message.resolve(), timestamp = message.timestamp, @@ -53,7 +57,11 @@ fun SystemMessageComposable(message: ChatMessageUiState.SystemMessageUi, fontSiz * Renders a notice message from Twitch */ @Composable -fun NoticeMessageComposable(message: ChatMessageUiState.NoticeMessageUi, fontSize: Float, modifier: Modifier = Modifier) { +fun NoticeMessageComposable( + message: ChatMessageUiState.NoticeMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { SimpleMessageContainer( message = message.message, timestamp = message.timestamp, @@ -71,7 +79,12 @@ fun NoticeMessageComposable(message: ChatMessageUiState.NoticeMessageUi, fontSiz */ @Suppress("DEPRECATION") @Composable -fun UserNoticeMessageComposable(message: ChatMessageUiState.UserNoticeMessageUi, fontSize: Float, modifier: Modifier = Modifier, highlightShape: Shape = RectangleShape) { +fun UserNoticeMessageComposable( + message: ChatMessageUiState.UserNoticeMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, +) { val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = MaterialTheme.colorScheme.onSurface val linkColor = MaterialTheme.colorScheme.primary @@ -80,72 +93,77 @@ fun UserNoticeMessageComposable(message: ChatMessageUiState.UserNoticeMessageUi, val textSize = fontSize.sp val context = LocalContext.current - val annotatedString = remember(message, textColor, nameColor, linkColor, timestampColor, textSize) { - buildAnnotatedString { - // Timestamp - if (message.timestamp.isNotEmpty()) { - withStyle(timestampSpanStyle(textSize.value, timestampColor)) { - append(message.timestamp) + val annotatedString = + remember(message, textColor, nameColor, linkColor, timestampColor, textSize) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(textSize.value, timestampColor)) { + append(message.timestamp) + } + append(" ") } - append(" ") - } - // 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 - } + // 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) + 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, fontWeight = FontWeight.Bold)) { - append(msgText.substring(nameIndex, nameIndex + displayName.length)) - } + // Colored username + withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { + 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) + // 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) + 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 = 2.dp, vertical = 2.dp), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(bgColor, highlightShape) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { ClickableText( text = annotatedString, style = TextStyle(fontSize = textSize), modifier = Modifier.fillMaxWidth(), onClick = { offset -> - annotatedString.getStringAnnotations("URL", offset, offset) - .firstOrNull()?.let { annotation -> + annotatedString + .getStringAnnotations("URL", offset, offset) + .firstOrNull() + ?.let { annotation -> launchCustomTab(context, annotation.item) } }, @@ -157,7 +175,11 @@ fun UserNoticeMessageComposable(message: ChatMessageUiState.UserNoticeMessageUi, * Renders a date separator between messages from different days */ @Composable -fun DateSeparatorComposable(message: ChatMessageUiState.DateSeparatorUi, fontSize: Float, modifier: Modifier = Modifier) { +fun DateSeparatorComposable( + message: ChatMessageUiState.DateSeparatorUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { SimpleMessageContainer( message = message.dateText, timestamp = message.timestamp, @@ -170,14 +192,23 @@ fun DateSeparatorComposable(message: ChatMessageUiState.DateSeparatorUi, fontSiz } @Immutable -private data class StyledRange(val start: Int, val length: Int, val color: Color, val bold: Boolean) +private data class StyledRange( + val start: Int, + val length: Int, + val color: Color, + val bold: Boolean, +) /** * Renders a moderation message (timeouts, bans, deletions) with colored usernames. */ @Suppress("DEPRECATION") @Composable -fun ModerationMessageComposable(message: ChatMessageUiState.ModerationMessageUi, fontSize: Float, modifier: Modifier = Modifier) { +fun ModerationMessageComposable( + message: ChatMessageUiState.ModerationMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) val timestampColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) @@ -190,84 +221,98 @@ fun ModerationMessageComposable(message: ChatMessageUiState.ModerationMessageUi, val dimmedTextColor = textColor.copy(alpha = 0.7f) - val annotatedString = remember( - message, resolvedMessage, 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, bold = true)) - 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, bold = true)) - } - } - for (arg in message.arguments) { - 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, bold = false)) - } - } - }.sortedBy { it.start } + val annotatedString = + remember( + message, + resolvedMessage, + 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, bold = true)) + 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, bold = true)) + } + } + for (arg in message.arguments) { + 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, bold = false)) + } + } + }.sortedBy { it.start } - buildAnnotatedString { - // Timestamp - if (message.timestamp.isNotEmpty()) { - withStyle(timestampSpanStyle(textSize.value, timestampColor)) { - append(message.timestamp) + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(textSize.value, timestampColor)) { + append(message.timestamp) + } + append(" ") } - append(" ") - } - // 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)) + // 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)) + } } + val style = + when { + range.bold -> SpanStyle(color = range.color, fontWeight = FontWeight.Bold) + else -> SpanStyle(color = range.color) + } + withStyle(style) { + append(resolvedMessage.substring(range.start, range.start + range.length)) + } + cursor = range.start + range.length } - val style = when { - range.bold -> SpanStyle(color = range.color, fontWeight = FontWeight.Bold) - else -> SpanStyle(color = range.color) - } - withStyle(style) { - 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)) + if (cursor < resolvedMessage.length) { + withStyle(SpanStyle(color = dimmedTextColor)) { + append(resolvedMessage.substring(cursor)) + } } } } - } Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .alpha(message.textAlpha) - .background(bgColor) - .padding(horizontal = 2.dp, vertical = 2.dp), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(bgColor) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { ClickableText( text = annotatedString, style = TextStyle(fontSize = textSize), modifier = Modifier.fillMaxWidth(), onClick = { offset -> - annotatedString.getStringAnnotations("URL", offset, offset) - .firstOrNull()?.let { annotation -> + annotatedString + .getStringAnnotations("URL", offset, offset) + .firstOrNull() + ?.let { annotation -> launchCustomTab(context, annotation.item) } }, 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 index ce29a4d01..dd38bbaf9 100644 --- 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 @@ -66,13 +66,14 @@ fun WhisperMessageComposable( Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .alpha(message.textAlpha) - .background(backgroundColor) - .indication(interactionSource, ripple()) - .padding(horizontal = 2.dp, vertical = 2.dp), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(backgroundColor) + .indication(interactionSource, ripple()) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { Box(modifier = Modifier.weight(1f)) { WhisperMessageText( @@ -118,85 +119,86 @@ private fun WhisperMessageText( val linkColor = MaterialTheme.colorScheme.primary // 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) - append(" ") + val annotatedString = + remember(message, defaultTextColor, senderColor, recipientColor, linkColor) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { + append(message.timestamp) + append(" ") + } } - } - // Badges (using appendInlineContent for proper rendering) - message.badges.forEach { badge -> - appendInlineContent("BADGE_${badge.position}", "[badge]") - append(" ") // Space between badges - } + // 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(" -> ") - } + // 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(": ") - } + // 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) - } + // 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) + // 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(" ") - } + // 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 - } + 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) + // 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, @@ -206,21 +208,28 @@ private fun WhisperMessageText( animateGifs = animateGifs, onEmoteClick = onEmoteClick, onTextClick = { offset -> - annotatedString.getStringAnnotations("USER", offset, offset) - .firstOrNull()?.let { annotation -> + annotatedString + .getStringAnnotations("USER", offset, offset) + .firstOrNull() + ?.let { annotation -> parseUserAnnotation(annotation.item)?.let { user -> onUserClick(user.userId, user.userName, user.displayName, message.badges, false) } } - annotatedString.getStringAnnotations("URL", offset, offset) - .firstOrNull()?.let { annotation -> + annotatedString + .getStringAnnotations("URL", offset, offset) + .firstOrNull() + ?.let { annotation -> launchCustomTab(context, annotation.item) } }, onTextLongClick = { offset -> - val user = annotatedString.getStringAnnotations("USER", offset, offset) - .firstOrNull()?.let { parseUserAnnotation(it.item) } + val user = + annotatedString + .getStringAnnotations("USER", offset, offset) + .firstOrNull() + ?.let { parseUserAnnotation(it.item) } when { user != null -> onUserClick(user.userId, user.userName, user.displayName, message.badges, true) @@ -234,51 +243,58 @@ private fun WhisperMessageText( * Renders a channel point redemption message */ @Composable -fun PointRedemptionMessageComposable(message: ChatMessageUiState.PointRedemptionMessageUi, fontSize: Float, modifier: Modifier = Modifier, highlightShape: Shape = RectangleShape) { +fun PointRedemptionMessageComposable( + message: ChatMessageUiState.PointRedemptionMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, +) { val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val timestampColor = rememberAdaptiveTextColor(backgroundColor) Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .alpha(message.textAlpha) - .background(backgroundColor, highlightShape) - .padding(horizontal = 2.dp, vertical = 2.dp), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(backgroundColor, highlightShape) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - val annotatedString = remember(message, timestampColor) { - buildAnnotatedString { - // Timestamp - if (message.timestamp.isNotEmpty()) { - withStyle(timestampSpanStyle(fontSize, timestampColor)) { - append(message.timestamp) + val annotatedString = + remember(message, timestampColor) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(fontSize, timestampColor)) { + append(message.timestamp) + } + append(" ") } - append(" ") - } - when { - message.requiresUserInput -> { - append("Redeemed ") - } + when { + message.requiresUserInput -> { + append("Redeemed ") + } - message.nameText != null -> { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(message.nameText) + message.nameText != null -> { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(message.nameText) + } + append(" redeemed ") } - append(" redeemed ") } - } - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(message.title) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(message.title) + } + append(" ") } - append(" ") } - } BasicText( text = annotatedString, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt index 708209698..24d4af925 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt @@ -24,13 +24,18 @@ private val FfzModGreen = Color(0xFF34AE0A) * 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) { +fun BadgeInlineContent( + badge: BadgeUi, + size: Dp, + modifier: Modifier = Modifier, +) { when (badge.badge) { is Badge.FFZModBadge -> { Box( - modifier = modifier - .size(size) - .background(FfzModGreen), + modifier = + modifier + .size(size) + .background(FfzModGreen), ) { AsyncImage( model = badge.url, @@ -44,9 +49,10 @@ fun BadgeInlineContent(badge: BadgeUi, size: Dp, modifier: Modifier = Modifier) AsyncImage( model = badge.drawableResId ?: badge.url, contentDescription = badge.badge.type.name, - modifier = modifier - .size(size) - .clip(CircleShape), + modifier = + modifier + .size(size) + .clip(CircleShape), ) } @@ -64,7 +70,14 @@ fun BadgeInlineContent(badge: BadgeUi, size: Dp, modifier: Modifier = Modifier) * Renders an emote (potentially stacked) as inline content in a message. */ @Composable -fun EmoteInlineContent(emote: EmoteUi, fontSize: Float, coordinator: EmoteAnimationCoordinator, animateGifs: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { +fun EmoteInlineContent( + emote: EmoteUi, + fontSize: Float, + coordinator: EmoteAnimationCoordinator, + animateGifs: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { StackedEmote( emote = emote, fontSize = fontSize, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt index d47d4892a..73eac368e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt @@ -22,7 +22,11 @@ import com.flxrs.dankchat.ui.chat.EmoteUi /** * Appends a formatted timestamp to the AnnotatedString builder. */ -fun AnnotatedString.Builder.appendTimestamp(timestamp: String, fontSize: TextUnit, color: Color) { +fun AnnotatedString.Builder.appendTimestamp( + timestamp: String, + fontSize: TextUnit, + color: Color, +) { if (timestamp.isNotEmpty()) { withStyle( SpanStyle( @@ -52,7 +56,11 @@ fun AnnotatedString.Builder.appendBadges(badges: List) { /** * Appends message text with emotes, handling emote inline content and spacing. */ -fun AnnotatedString.Builder.appendMessageWithEmotes(message: String, emotes: List, textColor: Color) { +fun AnnotatedString.Builder.appendMessageWithEmotes( + message: String, + emotes: List, + textColor: Color, +) { withStyle(SpanStyle(color = textColor)) { var currentPos = 0 emotes.sortedBy { it.position.first }.forEach { emote -> @@ -95,7 +103,14 @@ fun AnnotatedString.Builder.appendMessageWithEmotes(message: String, emotes: Lis /** * Appends a clickable username with annotation for click handling. */ -fun AnnotatedString.Builder.appendClickableUsername(displayText: String, userId: UserId?, userName: UserName, displayName: DisplayName, channel: String = "", color: Color) { +fun AnnotatedString.Builder.appendClickableUsername( + displayText: String, + userId: UserId?, + userName: UserName, + displayName: DisplayName, + channel: String = "", + color: Color, +) { if (displayText.isNotEmpty()) { withStyle( SpanStyle( @@ -119,7 +134,12 @@ fun AnnotatedString.Builder.appendClickableUsername(displayText: String, userId: } } -data class UserAnnotation(val userId: String?, val userName: String, val displayName: String, val channel: String?) +data class UserAnnotation( + val userId: String?, + val userName: String, + val displayName: String, + val channel: String?, +) fun parseUserAnnotation(annotation: String): UserAnnotation? { val parts = annotation.split("|") 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 index b5c56d5c3..eb10d5c0d 100644 --- 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 @@ -45,59 +45,61 @@ fun MessageTextWithInlineContent( val density = LocalDensity.current val badgeSize = EmoteScaling.getBadgeSize(fontSize) - val inlineContentProviders: Map Unit> = remember(badges, emotes, fontSize) { - buildMap Unit> { - badges.forEach { badge -> - put("BADGE_${badge.position}") { - BadgeInlineContent(badge = badge, size = badgeSize) + val inlineContentProviders: Map 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) }, - ) + emotes.forEach { emote -> + put("EMOTE_${emote.code}") { + StackedEmote( + emote = emote, + fontSize = fontSize, + emoteCoordinator = emoteCoordinator, + animateGifs = animateGifs, + modifier = Modifier, + onClick = { onEmoteClick(emote.emotes) }, + ) + } } } } - } - 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 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 = EmoteScaling.getBaseHeight(fontSize) - val baseHeightPx = with(density) { baseHeight.toPx().toInt() } - emotes.forEach { emote -> - val id = "EMOTE_${emote.code}" - when { - emote.urls.size == 1 -> { - val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) + val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + emotes.forEach { emote -> + val id = "EMOTE_${emote.code}" + when { + emote.urls.size == 1 -> { + val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } } - } - else -> { - val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" - val dims = emoteCoordinator.dimensionCache.get(cacheKey) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) + else -> { + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + val dims = emoteCoordinator.dimensionCache.get(cacheKey) + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } } } } } } - } TextWithMeasuredInlineContent( text = annotatedString, @@ -111,9 +113,13 @@ fun MessageTextWithInlineContent( ) } -fun launchCustomTab(context: Context, url: String) { +fun launchCustomTab( + context: Context, + url: String, +) { try { - CustomTabsIntent.Builder() + CustomTabsIntent + .Builder() .setShowTitle(true) .build() .launchUrl(context, url.toUri()) @@ -122,7 +128,10 @@ fun launchCustomTab(context: Context, url: String) { } } -fun timestampSpanStyle(fontSize: Float, color: Color) = SpanStyle( +fun timestampSpanStyle( + fontSize: Float, + color: Color, +) = SpanStyle( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = (fontSize * 0.95f).sp, 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 index dea1d979c..ece95d643 100644 --- 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 @@ -30,40 +30,52 @@ import com.flxrs.dankchat.ui.chat.rememberBackgroundColor */ @Suppress("DEPRECATION") @Composable -fun SimpleMessageContainer(message: String, timestamp: String, fontSize: TextUnit, lightBackgroundColor: Color, darkBackgroundColor: Color, textAlpha: Float, modifier: Modifier = Modifier) { +fun SimpleMessageContainer( + message: String, + timestamp: String, + fontSize: TextUnit, + lightBackgroundColor: Color, + darkBackgroundColor: Color, + textAlpha: Float, + modifier: Modifier = Modifier, +) { val bgColor = rememberBackgroundColor(lightBackgroundColor, darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) val linkColor = MaterialTheme.colorScheme.primary val timestampColor = MaterialTheme.colorScheme.onSurface val context = LocalContext.current - val annotatedString = remember(message, timestamp, textColor, linkColor, timestampColor, fontSize) { - buildAnnotatedString { - withStyle(timestampSpanStyle(fontSize.value, timestampColor)) { - append(timestamp) - } - append(" ") - withStyle(SpanStyle(color = textColor)) { - appendWithLinks(message, linkColor) + val annotatedString = + remember(message, timestamp, textColor, linkColor, timestampColor, fontSize) { + buildAnnotatedString { + withStyle(timestampSpanStyle(fontSize.value, timestampColor)) { + append(timestamp) + } + append(" ") + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(message, linkColor) + } } } - } Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .alpha(textAlpha) - .background(bgColor) - .padding(horizontal = 2.dp, vertical = 2.dp), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(textAlpha) + .background(bgColor) + .padding(horizontal = 2.dp, vertical = 2.dp), ) { ClickableText( text = annotatedString, style = TextStyle(fontSize = fontSize), modifier = Modifier.fillMaxWidth(), onClick = { offset -> - annotatedString.getStringAnnotations("URL", offset, offset) - .firstOrNull()?.let { annotation -> + annotatedString + .getStringAnnotations("URL", offset, offset) + .firstOrNull() + ?.let { annotation -> launchCustomTab(context, annotation.item) } }, 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 index 2b870519b..bcdfd6913 100644 --- 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 @@ -50,10 +50,11 @@ fun RepliesComposable( ChatScreen( messages = (uiState as RepliesUiState.Found).items, fontSize = displaySettings.fontSize, - callbacks = ChatScreenCallbacks( - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - ), + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = modifier, 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 index 6081d63fa..f9c5db9d7 100644 --- 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 @@ -8,12 +8,16 @@ import com.flxrs.dankchat.ui.chat.ChatMessageUiState sealed interface RepliesState { data object NotFound : RepliesState - data class Found(val items: List) : RepliesState + data class Found( + val items: List, + ) : RepliesState } @Immutable sealed interface RepliesUiState { data object NotFound : RepliesUiState - data class Found(val items: List) : RepliesUiState + data class Found( + val items: List, + ) : RepliesUiState } 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 index 8f68cb76c..cf2b9f2c6 100644 --- 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 @@ -7,7 +7,10 @@ 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 { + fun matches( + item: ChatItem, + filters: List, + ): Boolean { if (filters.isEmpty()) return true return filters.all { filter -> val result = @@ -22,51 +25,68 @@ object ChatItemFilter { } } - 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) - } - - else -> { - false + 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 matchLink(item: ChatItem): Boolean = when (val message = item.message) { - is PrivMessage -> URL_REGEX.containsMatchIn(message.message) - 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) + } - 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 } } - else -> { - false + private fun matchLink(item: ChatItem): Boolean = + when (val message = item.message) { + is PrivMessage -> URL_REGEX.containsMatchIn(message.message) + 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 + 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 } } - 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 index 806327fab..184e01a7d 100644 --- 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 @@ -6,13 +6,27 @@ import androidx.compose.runtime.Immutable sealed interface ChatSearchFilter { val negate: Boolean - data class Text(val query: String, override val negate: Boolean = false) : ChatSearchFilter + 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 Author( + val name: String, + override val negate: Boolean = false, + ) : ChatSearchFilter - data class HasLink(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 HasEmote( + val emoteName: String?, + override val negate: Boolean = false, + ) : ChatSearchFilter - data class BadgeFilter(val badgeName: 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 index 114efb37d..82f0e9cf0 100644 --- 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 @@ -13,7 +13,10 @@ object ChatSearchFilterParser { } } - private fun parseToken(token: String, isBeingTyped: Boolean): ChatSearchFilter? { + private fun parseToken( + token: String, + isBeingTyped: Boolean, + ): ChatSearchFilter? { if (token.isBlank()) return null val (negate, raw) = extractNegation(token) @@ -60,8 +63,9 @@ object ChatSearchFilterParser { 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 - } + 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 index 6318733c5..b9c09b48e 100644 --- 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 @@ -22,7 +22,11 @@ object SearchFilterSuggestions { 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 { + fun filter( + input: String, + users: Set = emptySet(), + badgeNames: Set = emptySet(), + ): List { val lastToken = input .trimEnd() 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 index cc79899c4..b31ded95f 100644 --- 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 @@ -8,23 +8,36 @@ import com.flxrs.dankchat.data.twitch.emote.GenericEmote @Immutable sealed interface Suggestion { - data class EmoteSuggestion(val emote: GenericEmote) : Suggestion { + data class EmoteSuggestion( + val emote: GenericEmote, + ) : Suggestion { override fun toString() = emote.toString() } - data class UserSuggestion(val name: DisplayName, val withLeadingAt: Boolean = false) : Suggestion { + 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 { + data class EmojiSuggestion( + val emoji: EmojiData, + ) : Suggestion { override fun toString() = emoji.unicode } - data class CommandSuggestion(val command: String) : Suggestion { + 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 { + 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 index ad626ba4b..f175b903d 100644 --- 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 @@ -22,7 +22,11 @@ class SuggestionProvider( private val emoteUsageRepository: EmoteUsageRepository, private val emojiRepository: EmojiRepository, ) { - fun getSuggestions(inputText: String, cursorPosition: Int, channel: UserName?): Flow> { + fun getSuggestions( + inputText: String, + cursorPosition: Int, + channel: UserName?, + ): Flow> { if (inputText.isBlank() || channel == null) { return flowOf(emptyList()) } @@ -63,29 +67,48 @@ class SuggestionProvider( } } - private fun getEmoteSuggestions(channel: UserName, constraint: String): Flow> = emoteRepository.getEmotes(channel).map { emotes -> - val recentIds = emoteUsageRepository.recentEmoteIds.value - filterEmotes(emotes.suggestions, constraint, recentIds) - } + private fun getEmoteSuggestions( + channel: UserName, + constraint: String, + ): Flow> = + emoteRepository.getEmotes(channel).map { emotes -> + val recentIds = emoteUsageRepository.recentEmoteIds.value + filterEmotes(emotes.suggestions, constraint, recentIds) + } - 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 getScoredEmoteSuggestions( + channel: UserName, + constraint: String, + ): Flow> = + emoteRepository.getEmotes(channel).map { emotes -> + val recentIds = emoteUsageRepository.recentEmoteIds.value + filterEmotesScored(emotes.suggestions, constraint, recentIds) + } - private fun getUserSuggestions(channel: UserName, constraint: String): Flow> = usersRepository.getUsersFlow(channel).map { displayNameSet -> - filterUsers(displayNameSet, constraint) - } + private fun getUserSuggestions( + channel: UserName, + constraint: String, + ): Flow> = + usersRepository.getUsersFlow(channel).map { displayNameSet -> + filterUsers(displayNameSet, constraint) + } - private fun getCommandSuggestions(channel: UserName, constraint: String): Flow> = combine( - commandRepository.getCommandTriggers(channel), - commandRepository.getSupibotCommands(channel), - ) { triggers, supibotCommands -> - filterCommands(triggers + supibotCommands, constraint) - } + private fun getCommandSuggestions( + channel: UserName, + constraint: String, + ): Flow> = + combine( + commandRepository.getCommandTriggers(channel), + commandRepository.getSupibotCommands(channel), + ) { triggers, supibotCommands -> + filterCommands(triggers + supibotCommands, constraint) + } // Merge two pre-sorted lists in O(n+m) without intermediate allocations - private fun mergeSorted(a: List, b: List): List { + private fun mergeSorted( + a: List, + b: List, + ): List { val result = mutableListOf() var i = 0 var j = 0 @@ -102,7 +125,10 @@ class SuggestionProvider( return result } - internal fun extractCurrentWord(text: String, cursorPosition: Int): String { + internal fun extractCurrentWord( + text: String, + cursorPosition: Int, + ): String { val cursorPos = cursorPosition.coerceIn(0, text.length) val separator = ' ' @@ -114,7 +140,11 @@ class SuggestionProvider( // 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 { + internal fun scoreEmote( + code: String, + query: String, + isRecentlyUsed: Boolean, + ): Int { val matchIndex = code.indexOf(query, ignoreCase = true) if (matchIndex < 0) return NO_MATCH @@ -130,24 +160,39 @@ class SuggestionProvider( } // Score raw GenericEmotes, only wrap matches - internal fun filterEmotes(emotes: List, constraint: String, recentEmoteIds: Set): List = - filterEmotesScored(emotes, constraint, recentEmoteIds).map { it.suggestion as Suggestion.EmoteSuggestion } - - private 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 } + internal fun filterEmotes( + emotes: List, + constraint: String, + recentEmoteIds: Set, + ): List = filterEmotesScored(emotes, constraint, recentEmoteIds).map { it.suggestion as Suggestion.EmoteSuggestion } + + private 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 } + 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 - internal fun filterUsers(users: Set, constraint: String): List { + internal fun filterUsers( + users: Set, + constraint: String, + ): List { val withAt = constraint.startsWith('@') return users .mapNotNull { name -> @@ -157,10 +202,14 @@ class SuggestionProvider( } // 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) } + 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 @@ -169,4 +218,7 @@ class SuggestionProvider( } } -internal class ScoredSuggestion(val suggestion: Suggestion, val score: Int) +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 index e1f5bca6e..fb90ec107 100644 --- 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 @@ -96,25 +96,28 @@ fun UserPopupDialog( when { isBlockConfirmation -> { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), + 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), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), ) { OutlinedButton(onClick = { showBlockConfirmation = false }, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.dialog_cancel)) @@ -132,9 +135,10 @@ fun UserPopupDialog( else -> { Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { when (state) { @@ -162,20 +166,22 @@ fun UserPopupDialog( 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() - }, + modifier = + Modifier.clickable { + onMention(userName.value, displayName.value) + onDismiss() + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) if (!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() - }, + modifier = + Modifier.clickable { + onWhisper(userName.value) + onDismiss() + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } @@ -183,10 +189,11 @@ fun UserPopupDialog( ListItem( headlineContent = { Text(stringResource(R.string.message_history)) }, leadingContent = { Icon(Icons.Default.History, contentDescription = null) }, - modifier = Modifier.clickable { - onMessageHistory(userName.value) - onDismiss() - }, + modifier = + Modifier.clickable { + onMessageHistory(userName.value) + onDismiss() + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } @@ -194,13 +201,14 @@ fun UserPopupDialog( 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 - } - }, + modifier = + Modifier.clickable { + if (isBlocked) { + onUnblockUser() + } else { + showBlockConfirmation = true + } + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } @@ -208,10 +216,11 @@ fun UserPopupDialog( ListItem( headlineContent = { Text(stringResource(R.string.user_popup_report)) }, leadingContent = { Icon(Icons.Default.Report, contentDescription = null) }, - modifier = Modifier.clickable { - onReport(userName.value) - onDismiss() - }, + modifier = + Modifier.clickable { + onReport(userName.value) + onDismiss() + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } @@ -226,11 +235,18 @@ fun UserPopupDialog( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun UserInfoSection(state: UserPopupState, userName: UserName, displayName: DisplayName, badges: List, onOpenChannel: (String) -> Unit) { +private fun UserInfoSection( + state: UserPopupState, + userName: UserName, + displayName: DisplayName, + badges: List, + onOpenChannel: (String) -> Unit, +) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), verticalAlignment = Alignment.Top, ) { when (state) { @@ -238,10 +254,11 @@ private fun UserInfoSection(state: UserPopupState, userName: UserName, displayNa AsyncImage( model = state.avatarUrl, contentDescription = null, - modifier = Modifier - .size(96.dp) - .clip(CircleShape) - .clickable { onOpenChannel(state.userName.value) }, + modifier = + Modifier + .size(96.dp) + .clip(CircleShape) + .clickable { onOpenChannel(state.userName.value) }, ) } @@ -274,9 +291,10 @@ private fun UserInfoSection(state: UserPopupState, userName: UserName, displayNa ) if (state.showFollowingSince) { Text( - text = state.followingSince?.let { - stringResource(R.string.user_popup_following_since, it) - } ?: stringResource(R.string.user_popup_not_following), + 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, ) @@ -320,15 +338,17 @@ private fun UserInfoSection(state: UserPopupState, userName: UserName, displayNa } private val UserPopupState.userName: UserName - get() = when (this) { - is UserPopupState.Loading -> userName - is UserPopupState.Success -> userName - is UserPopupState.Error -> 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("") - } + 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/ui/chat/user/UserPopupState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt index 2e00f81eb..c9a893303 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt @@ -7,9 +7,14 @@ import com.flxrs.dankchat.data.UserName @Immutable sealed interface UserPopupState { - data class Loading(val userName: UserName, val displayName: DisplayName) : UserPopupState + data class Loading( + val userName: UserName, + val displayName: DisplayName, + ) : UserPopupState - data class Error(val throwable: Throwable? = null) : UserPopupState + data class Error( + val throwable: Throwable? = null, + ) : UserPopupState data class Success( val userId: UserId, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt index 4723103bf..56456a3dc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt @@ -37,63 +37,72 @@ class UserPopupViewModel( loadData() } - fun blockUser() = updateStateWith { targetUserId, targetUsername -> - ignoresRepository.addUserBlock(targetUserId, targetUsername) - } - - fun unblockUser() = updateStateWith { targetUserId, targetUsername -> - ignoresRepository.removeUserBlock(targetUserId, targetUsername) - } - - private inline fun updateStateWith(crossinline block: suspend (targetUserId: UserId, targetUsername: UserName) -> Unit) = viewModelScope.launch { - if (!preferenceStore.isLoggedIn) { - return@launch + fun blockUser() = + updateStateWith { targetUserId, targetUsername -> + ignoresRepository.addUserBlock(targetUserId, targetUsername) } - val result = runCatching { block(params.targetUserId, params.targetUserName) } - when { - result.isFailure -> _userPopupState.value = UserPopupState.Error(result.exceptionOrNull()) - else -> loadData() + fun unblockUser() = + updateStateWith { targetUserId, targetUsername -> + ignoresRepository.removeUserBlock(targetUserId, targetUsername) } - } - private fun loadData() = viewModelScope.launch { - _userPopupState.value = UserPopupState.Loading(params.targetUserName, params.targetDisplayName) - val currentUserId = preferenceStore.userIdString - if (!preferenceStore.isLoggedIn || currentUserId == null) { - _userPopupState.value = UserPopupState.Error() - return@launch + private inline fun updateStateWith(crossinline block: suspend (targetUserId: UserId, targetUsername: UserName) -> Unit) = + viewModelScope.launch { + if (!preferenceStore.isLoggedIn) { + return@launch + } + + val result = runCatching { block(params.targetUserId, params.targetUserName) } + when { + result.isFailure -> _userPopupState.value = UserPopupState.Error(result.exceptionOrNull()) + else -> loadData() + } } - 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)) + private fun loadData() = + viewModelScope.launch { + _userPopupState.value = UserPopupState.Loading(params.targetUserName, params.targetDisplayName) + val currentUserId = preferenceStore.userIdString + if (!preferenceStore.isLoggedIn || currentUserId == null) { + _userPopupState.value = UserPopupState.Error() + return@launch + } - 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)) - mapToState( - user = user.await(), - showFollowing = canLoadFollows, - channelUserFollows = channelUserFollows.await(), - isBlocked = isBlocked, - ) - } + val channelUserFollows = + async { + channelId?.takeIf { canLoadFollows }?.let { dataRepository.getChannelFollowers(channelId, targetUserId) } + } + val user = + async { + dataRepository.getUser(targetUserId) + } - val state = result.getOrElse { UserPopupState.Error(it) } - _userPopupState.value = state - } + 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( @@ -104,11 +113,11 @@ class UserPopupViewModel( created = user.createdAt.asParsedZonedDateTime(), showFollowingSince = showFollowing, followingSince = - channelUserFollows - ?.data - ?.firstOrNull() - ?.followedAt - ?.asParsedZonedDateTime(), + channelUserFollows + ?.data + ?.firstOrNull() + ?.followedAt + ?.asParsedZonedDateTime(), isBlocked = isBlocked, ) } 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 index de09e8c60..5687d4255 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt @@ -38,7 +38,10 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LoginScreen(onLoginSuccess: () -> Unit, onCancel: () -> Unit) { +fun LoginScreen( + onLoginSuccess: () -> Unit, + onCancel: () -> Unit, +) { val viewModel: LoginViewModel = koinViewModel() var isLoading by remember { mutableStateOf(true) } var isZoomedOut by remember { mutableStateOf(false) } @@ -77,9 +80,10 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onCancel: () -> Unit) { }, ) { paddingValues -> Column( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize(), + modifier = + Modifier + .padding(paddingValues) + .fillMaxSize(), ) { if (isLoading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) @@ -88,10 +92,11 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onCancel: () -> Unit) { AndroidView( factory = { context -> WebView(context).also { webViewRef = it }.apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) @SuppressLint("SetJavaScriptEnabled") settings.javaScriptEnabled = true settings.setSupportZoom(true) @@ -99,26 +104,41 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onCancel: () -> Unit) { clearCache(true) clearFormData() - webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { - isLoading = true - } + 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 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 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?) { - Log.e("LoginScreen", "Error: ${error?.description}") - isLoading = false + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + Log.e("LoginScreen", "Error: ${error?.description}") + isLoading = false + } } - } loadUrl(viewModel.loginUrl) } 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 index 771675d08..d140f2535 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt @@ -12,37 +12,46 @@ import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @KoinViewModel -class LoginViewModel(private val authApiClient: AuthApiClient, private val authDataStore: AuthDataStore) : ViewModel() { - data class TokenParseEvent(val successful: Boolean) +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 - } + 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) - } + 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 suspend fun saveLoginDetails(oAuth: String, validateDto: ValidateDto): TokenParseEvent { + private suspend fun saveLoginDetails( + oAuth: String, + validateDto: ValidateDto, + ): TokenParseEvent { authDataStore.login( oAuthKey = oAuth, userName = validateDto.login.value.lowercase(), 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 index d4ee67936..4b95a277e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt @@ -15,36 +15,42 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp @Composable -fun DraggableHandle(onDrag: (deltaPx: Float) -> Unit, modifier: Modifier = Modifier) { +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) - } - }, + 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, - shape = RoundedCornerShape(8.dp), - ), + modifier = + Modifier + .width(16.dp) + .height(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + 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), - ), + 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 index 1ed1d8b16..dfadfa71d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt @@ -26,12 +26,18 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R @Composable -fun EmptyStateContent(isLoggedIn: Boolean, onAddChannel: () -> Unit, onLogin: () -> Unit, modifier: Modifier = Modifier) { +fun EmptyStateContent( + isLoggedIn: Boolean, + onAddChannel: () -> Unit, + onLogin: () -> Unit, + modifier: Modifier = Modifier, +) { Surface(modifier = modifier) { Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { 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 index 17fae6ff8..28931766a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -156,16 +156,17 @@ fun FloatingToolbar( // Dismiss scrim for menus if (showOverflowMenu || showQuickSwitch) { Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - showOverflowMenu = false - showQuickSwitch = false - overflowInitialMenu = AppBarMenu.Main - }, + modifier = + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + showOverflowMenu = false + showQuickSwitch = false + overflowInitialMenu = AppBarMenu.Main + }, ) } @@ -175,35 +176,37 @@ fun FloatingToolbar( 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 }, + 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 = with(density) { 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) - } + 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) + } Box(modifier = scrimModifier) { // Center selected tab when selection changes @@ -243,13 +246,14 @@ fun FloatingToolbar( } Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .onSizeChanged { - val h = it.height.toFloat() - if (toolbarRowHeight == 0f || h < toolbarRowHeight) toolbarRowHeight = h - }, + 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 @@ -269,80 +273,87 @@ fun FloatingToolbar( 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), - ) - } - }, + 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), + modifier = + Modifier + .padding(horizontal = 12.dp) + .onSizeChanged { tabViewportWidth = it.width } + .clipToBounds() + .horizontalScroll(tabScrollState), ) { tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex - val textColor = when { - isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - } + val textColor = + when { + isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .combinedClickable( - onClick = { onAction(ToolbarAction.SelectTab(index)) }, - onLongClick = { - showQuickSwitch = false - onAction(ToolbarAction.LongClickTab(index)) - overflowInitialMenu = AppBarMenu.Main - showOverflowMenu = true + modifier = + Modifier + .combinedClickable( + onClick = { onAction(ToolbarAction.SelectTab(index)) }, + onLongClick = { + showQuickSwitch = false + onAction(ToolbarAction.LongClickTab(index)) + overflowInitialMenu = AppBarMenu.Main + showOverflowMenu = true + }, + ).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 }, - ) - .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, @@ -364,26 +375,27 @@ fun FloatingToolbar( // Quick switch dropdown indicator (overlays end of tabs) if (hasOverflow) { Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .clickable { - showOverflowMenu = false - showQuickSwitch = !showQuickSwitch - } - .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)) - }, + modifier = + Modifier + .align(Alignment.CenterEnd) + .clickable { + showOverflowMenu = false + showQuickSwitch = !showQuickSwitch + }.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( @@ -402,20 +414,22 @@ fun FloatingToolbar( visible = showQuickSwitch, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - modifier = Modifier - .padding(top = 4.dp) - .endAlignedOverflow(), + modifier = + Modifier + .padding(top = 4.dp) + .endAlignedOverflow(), ) { var quickSwitchBackProgress by remember { mutableFloatStateOf(0f) } Surface( shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.graphicsLayer { - val scale = 1f - (quickSwitchBackProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - quickSwitchBackProgress - }, + modifier = + Modifier.graphicsLayer { + val scale = 1f - (quickSwitchBackProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - quickSwitchBackProgress + }, ) { PredictiveBackHandler { progress -> try { @@ -433,37 +447,40 @@ fun FloatingToolbar( val quickSwitchScrollAreaState = rememberScrollAreaState(quickSwitchScrollState) ScrollArea( state = quickSwitchScrollAreaState, - modifier = Modifier - .width(IntrinsicSize.Min) - .widthIn(min = 125.dp, max = 200.dp) - .heightIn(max = maxMenuHeight), + 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), + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(quickSwitchScrollState) + .padding(vertical = 8.dp), ) { tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onAction(ToolbarAction.SelectTab(index)) - showQuickSwitch = false - } - .padding(horizontal = 16.dp, vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { + onAction(ToolbarAction.SelectTab(index)) + showQuickSwitch = false + }.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Text( text = tab.displayName, style = MaterialTheme.typography.bodyLarge, - color = when { - isSelected -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface - }, + color = + when { + isSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -477,11 +494,12 @@ fun FloatingToolbar( } if (quickSwitchScrollState.maxValue > 0) { VerticalScrollbar( - modifier = Modifier - .align(Alignment.TopEnd) - .fillMaxHeight() - .width(3.dp) - .padding(vertical = 2.dp), + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .width(3.dp) + .padding(vertical = 2.dp), ) { Thumb( Modifier.background( @@ -531,17 +549,19 @@ fun FloatingToolbar( onAddChannelTooltipDismiss() } TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Above, - spacingBetweenTooltipAndAnchor = 8.dp, - ), + 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, - ) + 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)), @@ -579,11 +599,12 @@ fun FloatingToolbar( Icon( imageVector = Icons.Default.Notifications, contentDescription = stringResource(R.string.mentions_title), - tint = if (totalMentionCount > 0) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - }, + tint = + if (totalMentionCount > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + }, ) } } @@ -604,10 +625,11 @@ fun FloatingToolbar( visible = showOverflowMenu, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - modifier = Modifier - .skipIntrinsicHeight() - .padding(top = 4.dp) - .endAlignedOverflow(), + modifier = + Modifier + .skipIntrinsicHeight() + .padding(top = 4.dp) + .endAlignedOverflow(), ) { Surface( shape = MaterialTheme.shapes.large, @@ -638,46 +660,83 @@ fun FloatingToolbar( * 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) +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.minIntrinsicWidth( + measurable: IntrinsicMeasurable, + height: Int, + ): Int = 0 - override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurable: IntrinsicMeasurable, width: Int): Int = measurable.maxIntrinsicHeight(width) - }, -) + 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) +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.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) - }, -) + 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/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt index d1b81c873..c2877fdab 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -537,7 +537,10 @@ class MainActivity : AppCompatActivity() { } } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: android.content.res.Configuration) { + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: android.content.res.Configuration, + ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) mainEventBus.setInPipMode(isInPictureInPictureMode) } @@ -548,10 +551,11 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channelExtra)) } } - fun clearNotificationsOfChannel(channel: UserName) = when { - isBound && notificationService != null -> notificationService?.setActiveChannel(channel) - else -> pendingChannelsToClear += channel - } + fun clearNotificationsOfChannel(channel: UserName) = + when { + isBound && notificationService != null -> notificationService?.setActiveChannel(channel) + else -> pendingChannelsToClear += channel + } private fun handleShutDown() { stopService(Intent(this, NotificationService::class.java)) @@ -598,7 +602,10 @@ class MainActivity : AppCompatActivity() { } } - private fun uploadMedia(file: java.io.File, imageCapture: Boolean) { + private fun uploadMedia( + file: java.io.File, + imageCapture: Boolean, + ) { lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.UploadLoading) withContext(Dispatchers.IO) { @@ -625,7 +632,10 @@ class MainActivity : AppCompatActivity() { } private inner class TwitchServiceConnection : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { + override fun onServiceConnected( + className: ComponentName, + service: IBinder, + ) { val binder = service as NotificationService.LocalBinder notificationService = binder.service isBound = true 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 index 477bbf9b0..fa1105b0d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -73,12 +73,20 @@ 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) { +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) } @@ -88,7 +96,9 @@ fun InlineOverflowMenu(isLoggedIn: Boolean, onDismiss: () -> Unit, onAction: (To backProgress = event.progress } when (currentMenu) { - AppBarMenu.Main -> onDismiss() + AppBarMenu.Main -> { + onDismiss() + } else -> { backProgress = 0f @@ -112,15 +122,16 @@ fun InlineOverflowMenu(isLoggedIn: Boolean, onDismiss: () -> Unit, onAction: (To ScrollArea( state = scrollAreaState, - modifier = Modifier - .width(200.dp) - .heightIn(max = maxHeight) - .graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - }, + modifier = + Modifier + .width(200.dp) + .heightIn(max = maxHeight) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + }, ) { AnimatedContent( targetState = currentMenu, @@ -134,10 +145,11 @@ fun InlineOverflowMenu(isLoggedIn: Boolean, onDismiss: () -> Unit, onAction: (To label = "InlineMenuTransition", ) { menu -> Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(vertical = 8.dp), ) { when (menu) { AppBarMenu.Main -> { @@ -227,11 +239,12 @@ fun InlineOverflowMenu(isLoggedIn: Boolean, onDismiss: () -> Unit, onAction: (To } if (scrollState.maxValue > 0) { VerticalScrollbar( - modifier = Modifier - .align(Alignment.TopEnd) - .fillMaxHeight() - .width(3.dp) - .padding(vertical = 2.dp), + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .width(3.dp) + .padding(vertical = 2.dp), ) { Thumb( Modifier.background( @@ -245,12 +258,18 @@ fun InlineOverflowMenu(isLoggedIn: Boolean, onDismiss: () -> Unit, onAction: (To } @Composable -private fun InlineMenuItem(text: String, icon: ImageVector, hasSubMenu: Boolean = false, onClick: () -> Unit) { +private fun InlineMenuItem( + text: String, + icon: ImageVector, + hasSubMenu: Boolean = false, + onClick: () -> Unit, +) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -280,12 +299,16 @@ private fun InlineMenuItem(text: String, icon: ImageVector, hasSubMenu: Boolean } @Composable -private fun InlineSubMenuHeader(title: String, onBack: () -> Unit) { +private fun InlineSubMenuHeader( + title: String, + onBack: () -> Unit, +) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onBack) - .padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onBack) + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( 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 index 9b83003c8..51611366f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt @@ -4,23 +4,37 @@ import com.flxrs.dankchat.data.UserName import java.io.File sealed interface MainEvent { - data class Error(val throwable: Throwable) : 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 UploadSuccess( + val url: String, + ) : MainEvent - data class UploadFailed(val errorMessage: String?, val mediaFile: File, val imageCapture: Boolean) : MainEvent + data class UploadFailed( + val errorMessage: String?, + val mediaFile: File, + val imageCapture: Boolean, + ) : MainEvent - data class LoginValidated(val username: UserName) : MainEvent + data class LoginValidated( + val username: UserName, + ) : MainEvent - data class LoginOutdated(val username: UserName) : MainEvent + data class LoginOutdated( + val username: UserName, + ) : MainEvent data object LoginTokenInvalid : MainEvent data object LoginValidationFailed : MainEvent - data class OpenChannel(val channel: UserName) : MainEvent + data class OpenChannel( + val channel: UserName, + ) : MainEvent } 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 index a9e91bbde..c1c3a0ed4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -183,9 +183,10 @@ fun MainScreen( // 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 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 @@ -245,7 +246,9 @@ fun MainScreen( featureTourViewModel.addChannelTooltipState.dismiss() } - PostOnboardingStep.ToolbarPlusHint -> Unit + PostOnboardingStep.ToolbarPlusHint -> { + Unit + } } } @@ -306,14 +309,15 @@ fun MainScreen( } } - 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 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() } @@ -321,10 +325,11 @@ fun MainScreen( val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() - val composePagerState = rememberPagerState( - initialPage = pagerState.currentPage, - pageCount = { pagerState.channels.size }, - ).also { composePagerStateRef = it } + 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) } @@ -391,26 +396,29 @@ fun MainScreen( } Box( - modifier = Modifier - .fillMaxSize() - .then(if (!isFullscreen && !isInPipMode) Modifier.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) else Modifier), + modifier = + Modifier + .fillMaxSize() + .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) + 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 effectiveRoundedCorner = + when { + roundedCornerBottomPadding >= ROUNDED_CORNER_THRESHOLD -> roundedCornerBottomPadding + else -> 0.dp + } val totalMenuHeight = targetMenuHeight + navBarHeightDp // Shared scaffold bottom padding calculation @@ -425,44 +433,46 @@ fun MainScreen( showInput = effectiveShowInput && !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, - onToggleStream = { - when { - currentStream != null -> streamViewModel.closeStream() - else -> activeChannel?.let { streamViewModel.toggleStream(it) } - } - }, - onModActions = dialogViewModel::showModActions, - onInputActionsChange = mainScreenViewModel::updateInputActions, - onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, - onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, - onNewWhisper = if (inputState.isWhisperTabActive) { - dialogViewModel::showNewWhisper - } else { - null - }, - onRepeatedSendChange = chatInputViewModel::setRepeatedSend, - ), + 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, + onToggleStream = { + when { + currentStream != null -> streamViewModel.closeStream() + else -> activeChannel?.let { streamViewModel.toggleStream(it) } + } + }, + onModActions = dialogViewModel::showModActions, + onInputActionsChange = mainScreenViewModel::updateInputActions, + onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, + onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, + onNewWhisper = + if (inputState.isWhisperTabActive) { + dialogViewModel::showNewWhisper + } else { + null + }, + onRepeatedSendChange = chatInputViewModel::setRepeatedSend, + ), isUploading = dialogState.isUploading, isLoading = tabState.loading, isFullscreen = isFullscreen, @@ -470,20 +480,27 @@ fun MainScreen( isStreamActive = currentStream != null, 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() - } + inputActions = + when (fullScreenSheetState) { + is FullScreenSheetState.Replies -> { + persistentListOf(InputAction.LastMessage) + } - is FullScreenSheetState.History, - is FullScreenSheetState.Closed, - -> mainState.inputActions - }, + 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, @@ -492,19 +509,21 @@ fun MainScreen( 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, - ) - }, + 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, + ) + }, ) } @@ -531,21 +550,37 @@ fun MainScreen( channelTabViewModel.clearAllMentionCounts() } - ToolbarAction.Login -> onLogin() + ToolbarAction.Login -> { + onLogin() + } - ToolbarAction.Relogin -> onRelogin() + ToolbarAction.Relogin -> { + onRelogin() + } - ToolbarAction.Logout -> dialogViewModel.showLogout() + ToolbarAction.Logout -> { + dialogViewModel.showLogout() + } - ToolbarAction.ManageChannels -> dialogViewModel.showManageChannels() + ToolbarAction.ManageChannels -> { + dialogViewModel.showManageChannels() + } - ToolbarAction.OpenChannel -> onOpenChannel() + ToolbarAction.OpenChannel -> { + onOpenChannel() + } - ToolbarAction.RemoveChannel -> dialogViewModel.showRemoveChannel() + ToolbarAction.RemoveChannel -> { + dialogViewModel.showRemoveChannel() + } - ToolbarAction.ReportChannel -> onReportChannel() + ToolbarAction.ReportChannel -> { + onReportChannel() + } - ToolbarAction.BlockChannel -> dialogViewModel.showBlockChannel() + ToolbarAction.BlockChannel -> { + dialogViewModel.showBlockChannel() + } ToolbarAction.CaptureImage -> { if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else dialogViewModel.setPendingUploadAction(onCaptureImage) @@ -567,7 +602,9 @@ fun MainScreen( channelManagementViewModel.reconnect() } - ToolbarAction.OpenSettings -> onNavigateToSettings() + ToolbarAction.OpenSettings -> { + onNavigateToSettings() + } } } @@ -610,24 +647,25 @@ fun MainScreen( } // Shared pager callbacks - val chatPagerCallbacks = remember { - ChatPagerCallbacks( - onShowUserPopup = dialogViewModel::showUserPopup, - onMentionUser = chatInputViewModel::mentionUser, - onShowMessageOptions = dialogViewModel::showMessageOptions, - onShowEmoteInfo = dialogViewModel::showEmoteInfo, - onOpenReplies = sheetNavigationViewModel::openReplies, - onRecover = { - if (mainScreenViewModel.uiState.value.isFullscreen) mainScreenViewModel.toggleFullscreen() - if (!mainScreenViewModel.uiState.value.showInput) mainScreenViewModel.toggleInput() - mainScreenViewModel.resetGestureState() - }, - onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, - onTourAdvance = featureTourViewModel::advance, - onTourSkip = featureTourViewModel::skipTour, - scrollConnection = toolbarTracker, - ) - } + val chatPagerCallbacks = + remember { + ChatPagerCallbacks( + onShowUserPopup = dialogViewModel::showUserPopup, + onMentionUser = chatInputViewModel::mentionUser, + onShowMessageOptions = dialogViewModel::showMessageOptions, + onShowEmoteInfo = dialogViewModel::showEmoteInfo, + onOpenReplies = sheetNavigationViewModel::openReplies, + onRecover = { + if (mainScreenViewModel.uiState.value.isFullscreen) mainScreenViewModel.toggleFullscreen() + if (!mainScreenViewModel.uiState.value.showInput) mainScreenViewModel.toggleInput() + mainScreenViewModel.resetGestureState() + }, + onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, + onTourAdvance = featureTourViewModel::advance, + onTourSkip = featureTourViewModel::skipTour, + scrollConnection = toolbarTracker, + ) + } // Shared scaffold content (pager) val scaffoldContent: @Composable (PaddingValues, Dp) -> Unit = { paddingValues, chatTopPadding -> @@ -658,10 +696,11 @@ fun MainScreen( // Shared fullscreen sheet overlay val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> - val effectiveBottomPadding = when { - !effectiveShowInput -> bottomPadding + max(navBarHeightDp, effectiveRoundedCorner) - else -> bottomPadding - } + val effectiveBottomPadding = + when { + !effectiveShowInput -> bottomPadding + max(navBarHeightDp, effectiveRoundedCorner) + else -> bottomPadding + } FullScreenSheetOverlay( sheetState = fullScreenSheetState, mentionViewModel = mentionViewModel, @@ -686,16 +725,18 @@ fun MainScreen( var containerWidthPx by remember { mutableIntStateOf(0) } Box( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { containerWidthPx = it.size.width }, + modifier = + Modifier + .fillMaxSize() + .onGloballyPositioned { containerWidthPx = it.size.width }, ) { Row(modifier = Modifier.fillMaxSize()) { // Left pane: Stream Box( - modifier = Modifier - .weight(splitFraction) - .fillMaxSize(), + modifier = + Modifier + .weight(splitFraction) + .fillMaxSize(), ) { StreamView( channel = currentStream, @@ -712,16 +753,18 @@ fun MainScreen( // Right pane: Chat + all overlays Box( - modifier = Modifier - .weight(1f - splitFraction) - .fillMaxSize(), + modifier = + Modifier + .weight(1f - splitFraction) + .fillMaxSize(), ) { val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } Scaffold( - modifier = modifier - .fillMaxSize() - .padding(bottom = scaffoldBottomPadding), + modifier = + modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), contentWindowInsets = WindowInsets(0), snackbarHost = { SnackbarHost( @@ -760,14 +803,15 @@ fun MainScreen( // Input bar - rendered after sheet overlay so it's on top Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ), + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ), ) { bottomBar() } @@ -778,11 +822,12 @@ fun MainScreen( SuggestionDropdown( suggestions = inputState.suggestions, onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp), + modifier = + Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp), ) } } @@ -795,18 +840,20 @@ fun MainScreen( splitFraction = (splitFraction + deltaPx / containerWidthPx).coerceIn(0.2f, 0.8f) } }, - modifier = Modifier - .align(Alignment.CenterStart) - .graphicsLayer { translationX = containerWidthPx * splitFraction - 12.dp.toPx() }, + modifier = + Modifier + .align(Alignment.CenterStart) + .graphicsLayer { translationX = containerWidthPx * splitFraction - 12.dp.toPx() }, ) } } else { // --- Normal stacked layout (portrait / narrow-without-stream / PiP) --- if (!isInPipMode) { Scaffold( - modifier = modifier - .fillMaxSize() - .padding(bottom = scaffoldBottomPadding), + modifier = + modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), contentWindowInsets = WindowInsets(0), snackbarHost = { SnackbarHost( @@ -844,17 +891,18 @@ fun MainScreen( focusManager.clearFocus() streamViewModel.closeStream() }, - modifier = if (isInPipMode) { - Modifier.fillMaxSize() - } else { - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .graphicsLayer { alpha = streamState.alpha.value } - .onGloballyPositioned { coordinates -> - streamState.heightDp = with(density) { coordinates.size.height.toDp() } - } - }, + modifier = + if (isInPipMode) { + Modifier.fillMaxSize() + } else { + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .graphicsLayer { alpha = streamState.alpha.value } + .onGloballyPositioned { coordinates -> + streamState.heightDp = with(density) { coordinates.size.height.toDp() } + } + }, ) } if (!showStream) { @@ -866,9 +914,10 @@ fun MainScreen( if (currentStream != null && !isFullscreen && !isInPipMode) { StatusBarScrim( colorAlpha = 1f, - modifier = Modifier - .align(Alignment.TopCenter) - .graphicsLayer { alpha = streamState.alpha.value }, + modifier = + Modifier + .align(Alignment.TopCenter) + .graphicsLayer { alpha = streamState.alpha.value }, ) } @@ -903,14 +952,15 @@ fun MainScreen( // Input bar — on top of sheets and dismiss scrim for whisper/reply input if (!isInPipMode) { Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ), + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ), ) { bottomBar() } @@ -924,11 +974,12 @@ fun MainScreen( SuggestionDropdown( suggestions = inputState.suggestions, onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp), + modifier = + Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp), ) } } 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 index d5999d43d..f2409aaf1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -62,9 +62,10 @@ internal fun observePipMode(streamViewModel: StreamViewModel): Boolean { val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, _ -> - isInPipMode = activity?.isInPictureInPictureMode == true - } + val observer = + LifecycleEventObserver { _, _ -> + isInPipMode = activity?.isInPictureInPictureMode == true + } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } @@ -73,7 +74,8 @@ internal fun observePipMode(streamViewModel: StreamViewModel): Boolean { streamViewModel.shouldEnablePipAutoMode.collect { enabled -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && activity != null) { activity.setPictureInPictureParams( - PictureInPictureParams.Builder() + PictureInPictureParams + .Builder() .setAutoEnterEnabled(enabled) .setAspectRatio(Rational(16, 9)) .build(), @@ -116,13 +118,17 @@ internal fun FullscreenSystemBarsEffect(isFullscreen: Boolean) { * Additional graphicsLayer transforms (e.g. fade with stream) can be applied via [modifier]. */ @Composable -internal fun StatusBarScrim(modifier: Modifier = Modifier, colorAlpha: Float = 0.7f) { +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)), + modifier = + modifier + .fillMaxWidth() + .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .background(MaterialTheme.colorScheme.surface.copy(alpha = colorAlpha)), ) } @@ -130,18 +136,22 @@ internal fun StatusBarScrim(modifier: Modifier = Modifier, colorAlpha: Float = 0 * Fullscreen scrim that dismisses the input overflow menu when tapped. */ @Composable -internal fun InputDismissScrim(forceOpen: Boolean, onDismiss: () -> Unit) { +internal fun InputDismissScrim( + forceOpen: Boolean, + onDismiss: () -> Unit, +) { Box( - modifier = Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - if (!forceOpen) { - onDismiss() - } - }, + modifier = + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (!forceOpen) { + onDismiss() + } + }, ) } @@ -155,32 +165,35 @@ internal fun BoxScope.EdgeGestureGuards() { val layoutDirection = LocalLayoutDirection.current val systemGestureInsets = WindowInsets.systemGestures - val edgeGuardModifier = Modifier - .fillMaxHeight() - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown(pass = PointerEventPass.Initial) - down.consume() - do { - val event = awaitPointerEvent(pass = PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } while (event.changes.any { it.pressed }) + val edgeGuardModifier = + Modifier + .fillMaxHeight() + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + down.consume() + do { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + } } - } // Left edge guard Box( - modifier = Modifier - .align(AbsoluteAlignment.CenterLeft) - .width(with(density) { systemGestureInsets.getLeft(density, layoutDirection).toDp() }) - .then(edgeGuardModifier), + modifier = + Modifier + .align(AbsoluteAlignment.CenterLeft) + .width(with(density) { systemGestureInsets.getLeft(density, layoutDirection).toDp() }) + .then(edgeGuardModifier), ) // Right edge guard Box( - modifier = Modifier - .align(AbsoluteAlignment.CenterRight) - .width(with(density) { systemGestureInsets.getRight(density, layoutDirection).toDp() }) - .then(edgeGuardModifier), + modifier = + Modifier + .align(AbsoluteAlignment.CenterRight) + .width(with(density) { systemGestureInsets.getRight(density, layoutDirection).toDp() }) + .then(edgeGuardModifier), ) } @@ -189,7 +202,14 @@ internal fun BoxScope.EdgeGestureGuards() { * 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) { +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 }), @@ -197,17 +217,17 @@ internal fun EmoteMenuOverlay(isVisible: Boolean, totalMenuHeight: Dp, backProgr 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), + 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, 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 index d5620dcf1..6381f2b1d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -49,20 +49,26 @@ fun MainScreenEventHandler( LaunchedEffect(Unit) { mainEventBus.events.collect { event -> when (event) { - is MainEvent.LogOutRequested -> dialogViewModel.showLogout() + is MainEvent.LogOutRequested -> { + dialogViewModel.showLogout() + } - is MainEvent.UploadLoading -> dialogViewModel.setUploading(true) + is MainEvent.UploadLoading -> { + dialogViewModel.setUploading(true) + } is MainEvent.UploadSuccess -> { dialogViewModel.setUploading(false) - context.getSystemService() + 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.Long, - ) + val result = + snackbarHostState.showSnackbar( + message = resources.getString(R.string.snackbar_image_uploaded, event.url), + actionLabel = resources.getString(R.string.snackbar_paste), + duration = SnackbarDuration.Long, + ) if (result == SnackbarResult.ActionPerformed) { chatInputViewModel.insertText(event.url) } @@ -70,8 +76,9 @@ fun MainScreenEventHandler( 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) + 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.Long) } @@ -82,7 +89,9 @@ fun MainScreenEventHandler( (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) } - else -> Unit + else -> { + Unit + } } } } @@ -112,7 +121,9 @@ fun MainScreenEventHandler( ) } - else -> Unit + else -> { + Unit + } } } } @@ -128,15 +139,17 @@ fun MainScreenEventHandler( 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.Long, - ) + 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.Long, + ) 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 index e32af74bd..0af879441 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -84,9 +84,10 @@ internal fun MainScreenPagerContent( DankBackground(visible = showFullScreenLoading) if (showFullScreenLoading) { LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues), + modifier = + Modifier + .fillMaxWidth() + .padding(paddingValues), ) return@Box } @@ -99,9 +100,10 @@ internal fun MainScreenPagerContent( ) } else { Column( - modifier = Modifier - .fillMaxSize() - .padding(top = paddingValues.calculateTopPadding()), + modifier = + Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()), ) { Box(modifier = Modifier.fillMaxSize()) { HorizontalPager( @@ -114,10 +116,11 @@ internal fun MainScreenPagerContent( ChatComposable( channel = channel, onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> - val shouldOpenPopup = when (userLongClickBehavior) { - UserLongClickBehavior.MentionsUser -> !isLongPress - UserLongClickBehavior.OpensPopup -> isLongPress - } + val shouldOpenPopup = + when (userLongClickBehavior) { + UserLongClickBehavior.MentionsUser -> !isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress + } if (shouldOpenPopup) { callbacks.onShowUserPopup( UserPopupStateParams( @@ -154,22 +157,31 @@ internal fun MainScreenPagerContent( isFullscreen = isFullscreen, showFabs = !isSheetOpen, onRecover = callbacks.onRecover, - contentPadding = PaddingValues( - top = chatTopPadding + 56.dp, - bottom = paddingValues.calculateBottomPadding() + when { - effectiveShowInput -> inputHeightDp + contentPadding = + PaddingValues( + top = chatTopPadding + 56.dp, + bottom = + paddingValues.calculateBottomPadding() + + when { + effectiveShowInput -> { + inputHeightDp + } - !isFullscreen -> when { - helperTextHeightDp > 0.dp -> helperTextHeightDp - else -> max(navBarHeightDp, effectiveRoundedCorner) - } + !isFullscreen -> { + when { + helperTextHeightDp > 0.dp -> helperTextHeightDp + else -> max(navBarHeightDp, effectiveRoundedCorner) + } + } - else -> when { - helperTextHeightDp > 0.dp -> helperTextHeightDp - else -> effectiveRoundedCorner - } - }, - ), + else -> { + when { + helperTextHeightDp > 0.dp -> helperTextHeightDp + else -> effectiveRoundedCorner + } + } + }, + ), scrollModifier = if (callbacks.scrollConnection != null) Modifier.nestedScroll(callbacks.scrollConnection) else Modifier, onScrollToBottom = callbacks.onScrollToBottom, onScrollDirectionChange = { }, 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 index 36356f10d..ffa7da47e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -47,7 +47,6 @@ class MainScreenViewModel( private val developerSettingsDataStore: DeveloperSettingsDataStore, private val userStateRepository: UserStateRepository, ) : ViewModel() { - val globalLoadingState: StateFlow = channelDataCoordinator.globalLoadingState @@ -55,24 +54,25 @@ class MainScreenViewModel( private val _gestureInputHidden = MutableStateFlow(false) private val _gestureToolbarHidden = MutableStateFlow(false) - val uiState: StateFlow = combine( - appearanceSettingsDataStore.settings, - developerSettingsDataStore.settings, - _isFullscreen, - _gestureInputHidden, - _gestureToolbarHidden, - ) { appearance, developerSettings, isFullscreen, gestureInputHidden, gestureToolbarHidden -> - MainScreenUiState( - isFullscreen = isFullscreen, - showInput = appearance.showInput, - inputActions = appearance.inputActions.toImmutableList(), - showCharacterCounter = appearance.showCharacterCounter, - isRepeatedSendEnabled = developerSettings.repeatedSending, - debugMode = developerSettings.debugMode, - gestureInputHidden = gestureInputHidden, - gestureToolbarHidden = gestureToolbarHidden, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainScreenUiState()) + val uiState: StateFlow = + combine( + appearanceSettingsDataStore.settings, + developerSettingsDataStore.settings, + _isFullscreen, + _gestureInputHidden, + _gestureToolbarHidden, + ) { appearance, developerSettings, isFullscreen, gestureInputHidden, gestureToolbarHidden -> + MainScreenUiState( + isFullscreen = isFullscreen, + showInput = appearance.showInput, + inputActions = appearance.inputActions.toImmutableList(), + showCharacterCounter = appearance.showCharacterCounter, + isRepeatedSendEnabled = developerSettings.repeatedSending, + debugMode = developerSettings.debugMode, + gestureInputHidden = gestureInputHidden, + gestureToolbarHidden = gestureToolbarHidden, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainScreenUiState()) init { viewModelScope.launch { @@ -91,7 +91,9 @@ class MainScreenViewModel( appearance.copy(inputActions = actions - InputAction.Debug) } - else -> appearance + else -> { + appearance + } } } } @@ -141,7 +143,11 @@ class MainScreenViewModel( _keyboardHeightPx.value = persisted } - fun trackKeyboardHeight(heightPx: Int, isLandscape: Boolean, minHeightPx: Float) { + fun trackKeyboardHeight( + heightPx: Int, + isLandscape: Boolean, + minHeightPx: Float, + ) { if (heightPx > minHeightPx) { _keyboardHeightUpdates.tryEmit(KeyboardHeightUpdate(heightPx, isLandscape)) } @@ -172,4 +178,7 @@ class MainScreenViewModel( } } -private data class KeyboardHeightUpdate(val heightPx: Int, val isLandscape: Boolean) +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 index 5730a645f..b88ac53fd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -81,13 +81,14 @@ fun QuickActionsMenu( 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, - ) + val overflowItem = + getOverflowItem( + action = action, + isStreamActive = isStreamActive, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = isModerator, + ) if (overflowItem != null) { val actionEnabled = isActionEnabled(action, enabled, hasLastMessage) DropdownMenuItem( @@ -143,72 +144,115 @@ fun QuickActionsMenu( } } - else -> configureItem() + else -> { + configureItem() + } } } } } @Immutable -private data class OverflowItem(val labelRes: Int, val icon: ImageVector) +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, - ) +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.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.Default.VideocamOff else Icons.Default.Videocam, - ) + InputAction.Stream -> { + when { + hasStreamData || isStreamActive -> { + OverflowItem( + labelRes = if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream, + icon = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + ) + } - else -> null - } + else -> { + null + } + } + } - InputAction.ModActions -> when { - isModerator -> OverflowItem( - labelRes = R.string.menu_mod_actions, - icon = Icons.Default.Shield, - ) + InputAction.ModActions -> { + when { + isModerator -> { + OverflowItem( + labelRes = R.string.menu_mod_actions, + icon = Icons.Default.Shield, + ) + } - else -> null - } + 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.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.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, - ) -} + 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 -} +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) { +private fun EndCaretTourTooltip( + text: String, + onAction: () -> Unit, + onSkip: () -> Unit, +) { val containerColor = MaterialTheme.colorScheme.secondaryContainer Row(verticalAlignment = Alignment.CenterVertically) { Surface( @@ -219,9 +263,10 @@ private fun EndCaretTourTooltip(text: String, onAction: () -> Unit, onSkip: () - modifier = Modifier.widthIn(max = 220.dp), ) { Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 12.dp, bottom = 8.dp), + modifier = + Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp, bottom = 8.dp), ) { Text( text = text, @@ -242,12 +287,13 @@ private fun EndCaretTourTooltip(text: String, onAction: () -> Unit, onSkip: () - } } 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() - } + val path = + Path().apply { + moveTo(0f, 0f) + lineTo(size.width, size.height / 2f) + lineTo(0f, size.height) + close() + } drawPath(path, containerColor) } } @@ -263,7 +309,12 @@ private fun rememberStartAlignedTooltipPositionProvider(spacingBetweenTooltipAnd val spacingPx = with(LocalDensity.current) { spacingBetweenTooltipAndAnchor.roundToPx() } return remember(spacingPx) { object : PopupPositionProvider { - override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { val startX = anchorBounds.left - popupContentSize.width - spacingPx return if (startX >= 0) { // Fits to the start — vertically center on anchor @@ -274,10 +325,12 @@ private fun rememberStartAlignedTooltipPositionProvider(spacingBetweenTooltipAnd ) } else { // Not enough space — fall back to above, horizontally end-aligned with anchor - 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)) + 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/ui/main/RepeatedSendData.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt index 9bae50e26..b53704c05 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt @@ -3,4 +3,7 @@ package com.flxrs.dankchat.ui.main import androidx.compose.runtime.Immutable @Immutable -data class RepeatedSendData(val enabled: Boolean, val message: String) +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 index 3fe8c9743..14a5e926d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt @@ -14,7 +14,9 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.data.UserName @Stable -internal class StreamToolbarState(val alpha: Animatable) { +internal class StreamToolbarState( + val alpha: Animatable, +) { var heightDp by mutableStateOf(0.dp) private var prevHasVisibleStream by mutableStateOf(false) private var isKeyboardClosingWithStream by mutableStateOf(false) @@ -26,7 +28,10 @@ internal class StreamToolbarState(val alpha: Animatable 0.dp 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 index 49bfe0ba9..35aacc021 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt @@ -4,9 +4,13 @@ import androidx.compose.runtime.Immutable @Immutable sealed interface ToolbarAction { - data class SelectTab(val index: Int) : ToolbarAction + data class SelectTab( + val index: Int, + ) : ToolbarAction - data class LongClickTab(val index: Int) : ToolbarAction + data class LongClickTab( + val index: Int, + ) : ToolbarAction data object AddChannel : 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 index 22d2b41a1..440b49417 100644 --- 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 @@ -90,7 +90,10 @@ class ChannelManagementViewModel( } } - fun renameChannel(channel: UserName, displayName: String?) { + fun renameChannel( + channel: UserName, + displayName: String?, + ) { val rename = displayName?.ifBlank { null }?.let { UserName(it) } preferenceStore.setRenamedChannel(ChannelWithRename(channel, rename)) } @@ -116,17 +119,18 @@ class ChannelManagementViewModel( chatMessageRepository.clearMessages(channel) } - fun blockChannel(channel: UserName) = viewModelScope.launch { - runCatching { - if (!preferenceStore.isLoggedIn) { - return@launch - } + 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) + val channelId = channelRepository.getChannel(channel)?.id ?: return@launch + ignoresRepository.addUserBlock(channelId, channel) + removeChannel(channel) + } } - } fun selectChannel(channel: UserName) { chatChannelProvider.setActiveChannel(channel) 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 index 7714f1169..0c13f214e 100644 --- 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 @@ -32,9 +32,9 @@ class ChannelPagerViewModel( ChannelPagerUiState( channels = channels.map { it.channel }.toImmutableList(), currentPage = - channels - .indexOfFirst { it.channel == active } - .coerceAtLeast(0), + channels + .indexOfFirst { it.channel == active } + .coerceAtLeast(0), ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelPagerUiState()) @@ -62,7 +62,10 @@ class ChannelPagerViewModel( * 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? { + fun resolveJumpTarget( + channel: UserName, + messageId: String, + ): JumpTarget? { val channels = preferenceStore.channels val index = channels.indexOfFirst { it == channel } if (index < 0) return null @@ -73,7 +76,14 @@ class ChannelPagerViewModel( } @Immutable -data class JumpTarget(val channelIndex: Int, val channel: UserName, val messageId: String) +data class JumpTarget( + val channelIndex: Int, + val channel: UserName, + val messageId: String, +) @Immutable -data class ChannelPagerUiState(val channels: ImmutableList = persistentListOf(), val currentPage: Int = 0) +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 index 6540a80b6..1d287b04c 100644 --- 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 @@ -7,7 +7,18 @@ 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) +data class ChannelTabUiState( + val tabs: ImmutableList = persistentListOf(), + val selectedIndex: Int = 0, + val loading: Boolean = true, +) @Immutable -data class ChannelTabItem(val channel: UserName, val displayName: String, val isSelected: Boolean, val hasUnread: Boolean, val mentionCount: Int, val loadingState: ChannelLoadingState) +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 index bf2724d5f..917ff8b7b 100644 --- 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 @@ -50,8 +50,8 @@ class ChannelTabViewModel( ChannelTabItem( channel = channelWithRename.channel, displayName = - channelWithRename.rename?.value - ?: channelWithRename.channel.value, + channelWithRename.rename?.value + ?: channelWithRename.channel.value, isSelected = channelWithRename.channel == active, hasUnread = unread[channelWithRename.channel] ?: false, mentionCount = mentions[channelWithRename.channel] ?: 0, @@ -61,12 +61,12 @@ class ChannelTabViewModel( ChannelTabUiState( tabs = tabs.toImmutableList(), selectedIndex = - channels - .indexOfFirst { it.channel == active } - .coerceAtLeast(0), + channels + .indexOfFirst { it.channel == active } + .coerceAtLeast(0), loading = - globalState == GlobalLoadingState.Loading || - tabs.any { it.loadingState == ChannelLoadingState.Loading }, + globalState == GlobalLoadingState.Loading || + tabs.any { it.loadingState == ChannelLoadingState.Loading }, ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) 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 index 18d142c14..d89dfb029 100644 --- 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 @@ -7,7 +7,11 @@ import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.utils.compose.InputBottomSheet @Composable -fun AddChannelDialog(onDismiss: () -> Unit, onAddChannel: (UserName) -> Unit, isChannelAlreadyAdded: (String) -> Boolean) { +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), 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 index f599f20ed..09ce5c161 100644 --- 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 @@ -6,7 +6,13 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet @Composable -fun ConfirmationDialog(title: String, confirmText: String, onConfirm: () -> Unit, onDismiss: () -> Unit, dismissText: String = stringResource(R.string.dialog_cancel)) { +fun ConfirmationDialog( + title: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + dismissText: String = stringResource(R.string.dialog_cancel), +) { ConfirmationBottomSheet( title = title, confirmText = confirmText, 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 index 1823dba25..3711c17d8 100644 --- 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 @@ -13,7 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow import org.koin.android.annotation.KoinViewModel @KoinViewModel -class DialogStateViewModel(private val preferenceStore: DankChatPreferenceStore, private val toolsSettingsDataStore: ToolsSettingsDataStore) : ViewModel() { +class DialogStateViewModel( + private val preferenceStore: DankChatPreferenceStore, + private val toolsSettingsDataStore: ToolsSettingsDataStore, +) : ViewModel() { private val _state = MutableStateFlow(DialogState()) val state: StateFlow = _state.asStateFlow() 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 index bfba0f2f2..9a46bd354 100644 --- 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 @@ -41,7 +41,14 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmoteInfoDialog(items: List, isLoggedIn: Boolean, onUseEmote: (String) -> Unit, onCopyEmote: (String) -> Unit, onOpenLink: (String) -> Unit, onDismiss: () -> Unit) { +fun EmoteInfoDialog( + items: List, + isLoggedIn: Boolean, + onUseEmote: (String) -> Unit, + onCopyEmote: (String) -> Unit, + onOpenLink: (String) -> Unit, + onDismiss: () -> Unit, +) { val scope = rememberCoroutineScope() val pagerState = rememberPagerState(pageCount = { items.size }) @@ -93,15 +100,22 @@ fun EmoteInfoDialog(items: List, isLoggedIn: Boolean, onUseEmote } @Composable -private fun EmoteInfoContent(item: EmoteSheetItem, showUseEmote: Boolean, onUseEmote: () -> Unit, onCopyEmote: () -> Unit, onOpenLink: () -> Unit) { +private fun EmoteInfoContent( + item: EmoteSheetItem, + showUseEmote: Boolean, + onUseEmote: () -> Unit, + onCopyEmote: () -> Unit, + onOpenLink: () -> Unit, +) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), verticalAlignment = Alignment.Top, ) { AsyncImage( 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 index 6629d60ef..c90ee89f0 100644 --- 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 @@ -100,77 +100,85 @@ fun MessageOptionsDialog( 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 }, - ) + 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.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.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 }, - ) + 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 }, + ) + } } } } @@ -203,9 +211,10 @@ private fun MessageOptionsMainView( ) Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), ) { if (canReply) { MessageOptionItem(Icons.AutoMirrored.Filled.Reply, stringResource(R.string.message_reply), onReply) @@ -254,7 +263,11 @@ private fun MessageOptionsMainView( } @Composable -private fun MessageOptionItem(icon: ImageVector, text: String, onClick: () -> Unit) { +private fun MessageOptionItem( + icon: ImageVector, + text: String, + onClick: () -> Unit, +) { ListItem( headlineContent = { Text(text) }, leadingContent = { Icon(icon, contentDescription = null) }, @@ -264,16 +277,20 @@ private fun MessageOptionItem(icon: ImageVector, text: String, onClick: () -> Un } @Composable -private fun TimeoutSubView(onConfirm: (Int) -> Unit, onBack: () -> Unit) { +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), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), ) { Text( text = stringResource(R.string.confirm_user_timeout_title), @@ -286,9 +303,10 @@ private fun TimeoutSubView(onConfirm: (Int) -> Unit, onBack: () -> Unit) { text = choices[currentIndex], style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(vertical = 8.dp), + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp), ) Slider( @@ -299,9 +317,10 @@ private fun TimeoutSubView(onConfirm: (Int) -> Unit, onBack: () -> Unit) { ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), ) { OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.dialog_cancel)) @@ -315,27 +334,35 @@ private fun TimeoutSubView(onConfirm: (Int) -> Unit, onBack: () -> Unit) { } @Composable -private fun ConfirmationSubView(title: String, confirmText: String, onConfirm: () -> Unit, onBack: () -> Unit) { +private fun ConfirmationSubView( + title: String, + confirmText: String, + onConfirm: () -> Unit, + onBack: () -> Unit, +) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), + 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), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), ) { OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.dialog_cancel)) 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 index d4db8ed9f..44acc36f4 100644 --- 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 @@ -61,41 +61,54 @@ 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"), +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) -} +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 @@ -127,107 +140,127 @@ fun ModActionsDialog( 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, - ) + null -> { + ModActionsMainView( + roomState = roomState, + isBroadcaster = isBroadcaster, + isStreamActive = isStreamActive, + shieldModeActive = shieldModeActive, + onSendCommand = onSendCommand, + onShowSubView = { subView = it }, + onClearChat = { subView = SubView.ClearChatConfirm }, + onAnnounce = onAnnounce, + 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.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.ShieldModeConfirm -> ShieldModeConfirmSubView( - onConfirm = { - onSendCommand("/shield") - onDismiss() - }, - onBack = { subView = null }, - ) + 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.ClearChatConfirm -> ClearChatConfirmSubView( - onConfirm = { - onSendCommand("/clear") - onDismiss() - }, - onBack = { subView = null }, - ) + 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 }, + ) + } } } } @@ -248,11 +281,12 @@ private fun ModActionsMainView( onDismiss: () -> Unit, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), ) { // Room state section SectionHeader(stringResource(R.string.mod_actions_room_state_section)) @@ -281,15 +315,18 @@ private fun ModActionsMainView( onDismiss() } - else -> onShowSubView(SubView.ShieldModeConfirm) + else -> { + onShowSubView(SubView.ShieldModeConfirm) + } } }, label = { Text(stringResource(R.string.mod_actions_shield_mode)) }, - leadingIcon = if (isShieldActive) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else { - null - }, + leadingIcon = + if (isShieldActive) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, ) AssistChip( onClick = onClearChat, @@ -358,7 +395,12 @@ private fun SectionHeader(title: String) { @OptIn(ExperimentalLayoutApi::class) @Composable -private fun RoomStateModeChips(roomState: RoomState?, onSendCommand: (String) -> Unit, onShowSubView: (SubView) -> Unit, onDismiss: () -> Unit) { +private fun RoomStateModeChips( + roomState: RoomState?, + onSendCommand: (String) -> Unit, + onShowSubView: (SubView) -> Unit, + onDismiss: () -> Unit, +) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -371,11 +413,12 @@ private fun RoomStateModeChips(roomState: RoomState?, onSendCommand: (String) -> onDismiss() }, label = { Text(stringResource(R.string.room_state_emote_only)) }, - leadingIcon = if (isEmoteOnly) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else { - null - }, + leadingIcon = + if (isEmoteOnly) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, ) val isSubOnly = roomState?.isSubscriberMode == true @@ -386,11 +429,12 @@ private fun RoomStateModeChips(roomState: RoomState?, onSendCommand: (String) -> onDismiss() }, label = { Text(stringResource(R.string.room_state_subscriber_only)) }, - leadingIcon = if (isSubOnly) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else { - null - }, + leadingIcon = + if (isSubOnly) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, ) val isSlowMode = roomState?.isSlowMode == true @@ -404,18 +448,21 @@ private fun RoomStateModeChips(roomState: RoomState?, onSendCommand: (String) -> onDismiss() } - else -> onShowSubView(SubView.SlowMode) + 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 - }, + leadingIcon = + if (isSlowMode) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, ) val isUniqueChat = roomState?.isUniqueChatMode == true @@ -426,11 +473,12 @@ private fun RoomStateModeChips(roomState: RoomState?, onSendCommand: (String) -> onDismiss() }, label = { Text(stringResource(R.string.room_state_unique_chat)) }, - leadingIcon = if (isUniqueChat) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else { - null - }, + leadingIcon = + if (isUniqueChat) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, ) val isFollowerOnly = roomState?.isFollowMode == true @@ -444,31 +492,41 @@ private fun RoomStateModeChips(roomState: RoomState?, onSendCommand: (String) -> onDismiss() } - else -> onShowSubView(SubView.FollowerMode) + 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 - }, + 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)?) { +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(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), ) { Text( text = stringResource(titleRes), @@ -500,13 +558,17 @@ private fun PresetChips(titleRes: Int, presets: List, formatLabel: @Composa @OptIn(ExperimentalLayoutApi::class) @Composable -private fun FollowerPresetChips(onPresetClick: (FollowerPreset) -> Unit, onCustomClick: () -> Unit) { +private fun FollowerPresetChips( + onPresetClick: (FollowerPreset) -> Unit, + onCustomClick: () -> Unit, +) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), ) { Text( text = stringResource(R.string.room_state_follower_only), @@ -535,7 +597,14 @@ private fun FollowerPresetChips(onPresetClick: (FollowerPreset) -> Unit, onCusto } @Composable -private fun UserInputSubView(titleRes: Int, hintRes: Int, onConfirm: (String) -> Unit, defaultValue: String = "", keyboardType: KeyboardType = KeyboardType.Text, onDismiss: () -> Unit = {}) { +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() } @@ -557,11 +626,12 @@ private fun UserInputSubView(titleRes: Int, hintRes: Int, onConfirm: (String) -> } Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), ) { Text( text = stringResource(titleRes), @@ -576,23 +646,26 @@ private fun UserInputSubView(titleRes: Int, hintRes: Int, onConfirm: (String) -> 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), + 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), + modifier = + Modifier + .align(Alignment.End) + .padding(top = 8.dp), ) { Text(stringResource(R.string.dialog_ok)) } @@ -600,27 +673,33 @@ private fun UserInputSubView(titleRes: Int, hintRes: Int, onConfirm: (String) -> } @Composable -private fun ClearChatConfirmSubView(onConfirm: () -> Unit, onBack: () -> Unit) { +private fun ClearChatConfirmSubView( + onConfirm: () -> Unit, + onBack: () -> Unit, +) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), + 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), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), ) { OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.dialog_cancel)) @@ -634,21 +713,26 @@ private fun ClearChatConfirmSubView(onConfirm: () -> Unit, onBack: () -> Unit) { } @Composable -private fun ShieldModeConfirmSubView(onConfirm: () -> Unit, onBack: () -> Unit) { +private fun ShieldModeConfirmSubView( + onConfirm: () -> Unit, + onBack: () -> Unit, +) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), + 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), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), ) Text( @@ -658,9 +742,10 @@ private fun ShieldModeConfirmSubView(onConfirm: () -> Unit, onBack: () -> Unit) ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), ) { OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.dialog_cancel)) 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 index ea64e6035..fd2ce9da1 100644 --- 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 @@ -58,10 +58,11 @@ fun ChatBottomBar( AnimatedVisibility( visible = showInput, enter = EnterTransition.None, - exit = when { - instantHide -> ExitTransition.None - else -> slideOutVertically(targetOffsetY = { it }) - }, + exit = + when { + instantHide -> ExitTransition.None + else -> slideOutVertically(targetOffsetY = { it }) + }, ) { ChatInputLayout( textFieldState = textFieldState, @@ -80,9 +81,10 @@ fun ChatBottomBar( onOverflowExpandedChange = onOverflowExpandedChange, tourState = tourState, isRepeatedSendEnabled = isRepeatedSendEnabled, - modifier = Modifier.onGloballyPositioned { coordinates -> - onInputHeightChange(coordinates.size.height) - }, + modifier = + Modifier.onGloballyPositioned { coordinates -> + onInputHeightChange(coordinates.size.height) + }, ) } @@ -93,34 +95,41 @@ fun ChatBottomBar( val roomStateText = resolvedRoomState.joinToString(separator = ", ") val helperText = listOfNotNull(roomStateText.ifEmpty { null }, helperTextState.streamInfo).joinToString(separator = " - ") if (helperText.isNotEmpty()) { - val horizontalPadding = when { - isFullscreen && isInSplitLayout -> { - val rcPadding = rememberRoundedCornerHorizontalPadding(fallback = 16.dp) - val direction = LocalLayoutDirection.current - PaddingValues(start = 16.dp, end = rcPadding.calculateEndPadding(direction)) - } + 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) + isFullscreen -> { + rememberRoundedCornerHorizontalPadding(fallback = 16.dp) + } - else -> PaddingValues(horizontal = 16.dp) - } + else -> { + PaddingValues(horizontal = 16.dp) + } + } Surface( color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier - .fillMaxWidth() - .onGloballyPositioned { onHelperTextHeightChange(it.size.height) }, + modifier = + Modifier + .fillMaxWidth() + .onGloballyPositioned { onHelperTextHeightChange(it.size.height) }, ) { Text( text = helperText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - modifier = Modifier - .navigationBarsPadding() - .fillMaxWidth() - .padding(horizontalPadding) - .padding(vertical = 6.dp) - .basicMarquee(), + modifier = + Modifier + .navigationBarsPadding() + .fillMaxWidth() + .padding(horizontalPadding) + .padding(vertical = 6.dp) + .basicMarquee(), textAlign = TextAlign.Start, ) } 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 index 238cb4a44..43daab7c3 100644 --- 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 @@ -151,42 +151,47 @@ fun ChatInputLayout( 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 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 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 - } + 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 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() + } var visibleActions by remember { mutableStateOf(effectiveActions) } val quickActionsExpanded = overflowExpanded || tourState.forceOverflowOpen @@ -203,9 +208,10 @@ fun ChatInputLayout( modifier = Modifier.fillMaxWidth(), ) { Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding(), + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding(), ) { // Input mode overlay header AnimatedVisibility( @@ -213,12 +219,13 @@ fun ChatInputLayout( 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 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 -> "" + } InputOverlayHeader( text = headerText, onDismiss = onOverlayDismiss, @@ -229,10 +236,12 @@ fun ChatInputLayout( TextField( state = textFieldState, enabled = enabled && !tourState.isTourActive, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .padding(bottom = 0.dp), // Reduce bottom padding as actions are below + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .padding(bottom = 0.dp), + // Reduce bottom padding as actions are below label = { Text(hint) }, suffix = { Row( @@ -240,15 +249,18 @@ fun ChatInputLayout( modifier = Modifier.height(IntrinsicSize.Min), ) { when (characterCounter) { - is CharacterCounterState.Hidden -> Unit + is CharacterCounterState.Hidden -> { + Unit + } is CharacterCounterState.Visible -> { Text( text = characterCounter.text, - color = when { - characterCounter.isOverLimit -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, + color = + when { + characterCounter.isOverLimit -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, style = MaterialTheme.typography.labelSmall, ) } @@ -262,20 +274,22 @@ fun ChatInputLayout( 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() }, + 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, - ), + lineLimits = + TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = 5, + ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), onKeyboardAction = { if (canSend) onSend() }, ) @@ -294,16 +308,18 @@ fun ChatInputLayout( val style = MaterialTheme.typography.labelSmall val density = LocalDensity.current BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 4.dp) - .animateContentSize(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 4.dp) + .animateContentSize(), ) { val maxWidthPx = with(density) { maxWidth.roundToPx() } - val fitsOnOneLine = remember(combinedText, style, maxWidthPx) { - textMeasurer.measure(combinedText, style).size.width <= maxWidthPx - } + val fitsOnOneLine = + remember(combinedText, style, maxWidthPx) { + textMeasurer.measure(combinedText, style).size.width <= maxWidthPx + } when { fitsOnOneLine || streamInfoText == null || roomStateText.isEmpty() -> { Text( @@ -344,9 +360,10 @@ fun ChatInputLayout( exit = shrinkVertically() + fadeOut(), ) { LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), ) } @@ -398,14 +415,15 @@ fun ChatInputLayout( 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) - } - }, + 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 -> @@ -419,12 +437,13 @@ fun ChatInputLayout( } } QuickActionsMenu( - modifier = Modifier.graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - }, + modifier = + Modifier.graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + }, surfaceColor = surfaceColor, visibleActions = visibleActions, enabled = enabled, @@ -465,38 +484,53 @@ fun ChatInputLayout( } @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) - }, - ) +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 } - enabled -> Modifier.clickable( - interactionSource = null, - indication = null, - onClick = onSend, - ) + val gestureModifier = + when { + enabled && isRepeatedSendEnabled -> { + Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { onSend() }, + onLongPress = { onRepeatedSendChange(true) }, + onPress = { + tryAwaitRelease() + onRepeatedSendChange(false) + }, + ) + } + } - else -> Modifier - } + enabled -> { + Modifier.clickable( + interactionSource = null, + indication = null, + onClick = onSend, + ) + } + + else -> { + Modifier + } + } Box( contentAlignment = Alignment.Center, - modifier = modifier - .then(gestureModifier) - .padding(4.dp), + modifier = + modifier + .then(gestureModifier) + .padding(4.dp), ) { Icon( imageVector = Icons.AutoMirrored.Filled.Send, @@ -523,35 +557,51 @@ private fun InputActionButton( modifier: Modifier = Modifier, onDebugInfoClick: () -> Unit = {}, ) { - val (icon, contentDescription, onClick) = when (action) { - InputAction.Search -> Triple(Icons.Default.Search, R.string.message_history, onSearchClick) + val (icon, contentDescription, onClick) = + when (action) { + InputAction.Search -> { + Triple(Icons.Default.Search, R.string.message_history, onSearchClick) + } - InputAction.LastMessage -> Triple(Icons.Default.History, R.string.resume_scroll, onLastMessageClick) + InputAction.LastMessage -> { + Triple(Icons.Default.History, R.string.resume_scroll, onLastMessageClick) + } - InputAction.Stream -> Triple( - if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, - R.string.toggle_stream, - onToggleStream, - ) + InputAction.Stream -> { + Triple( + if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + R.string.toggle_stream, + onToggleStream, + ) + } - InputAction.ModActions -> Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) + InputAction.ModActions -> { + Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) + } - InputAction.Fullscreen -> Triple( - if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, - R.string.toggle_fullscreen, - onToggleFullscreen, - ) + InputAction.Fullscreen -> { + Triple( + if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + R.string.toggle_fullscreen, + onToggleFullscreen, + ) + } - InputAction.HideInput -> Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) + InputAction.HideInput -> { + Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) + } - InputAction.Debug -> Triple(Icons.Default.BugReport, R.string.input_action_debug, onDebugInfoClick) - } + InputAction.Debug -> { + Triple(Icons.Default.BugReport, R.string.input_action_debug, onDebugInfoClick) + } + } - val actionEnabled = when (action) { - InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true - InputAction.LastMessage -> enabled && hasLastMessage - InputAction.Stream, InputAction.ModActions -> enabled - } + 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, @@ -566,14 +616,18 @@ private fun InputActionButton( } @Composable -private fun InputOverlayHeader(text: String, onDismiss: () -> Unit) { +private fun InputOverlayHeader( + text: String, + onDismiss: () -> Unit, +) { Column { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), ) { Text( text = text, @@ -630,9 +684,10 @@ private fun InputActionsRow( onRepeatedSendChange: (Boolean) -> Unit = {}, ) { BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), ) { val iconSize = 40.dp // Fixed slots: emote + overflow + send (+ whisper if present) @@ -660,9 +715,10 @@ private fun InputActionsRow( ) { Icon( imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, - contentDescription = stringResource( - if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint, - ), + contentDescription = + stringResource( + if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint, + ), ) } 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 index dc57f1a2b..ea5a6dae5 100644 --- 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 @@ -35,9 +35,13 @@ data class ChatInputUiState( sealed interface InputOverlay { data object None : InputOverlay - data class Reply(val name: UserName) : InputOverlay + data class Reply( + val name: UserName, + ) : InputOverlay - data class Whisper(val target: UserName) : InputOverlay + data class Whisper( + val target: UserName, + ) : InputOverlay data object Announce : InputOverlay } @@ -47,10 +51,16 @@ sealed interface CharacterCounterState { data object Hidden : CharacterCounterState @Immutable - data class Visible(val text: String, val isOverLimit: Boolean) : CharacterCounterState + data class Visible( + val text: String, + val isOverLimit: Boolean, + ) : CharacterCounterState } @Immutable -data class HelperText(val roomStateParts: ImmutableList = persistentListOf(), val streamInfo: String? = null) { +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 index 3c2102b88..6c5ad0d59 100644 --- 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 @@ -74,7 +74,6 @@ class ChatInputViewModel( streamsSettingsDataStore: StreamsSettingsDataStore, streamDataRepository: StreamDataRepository, ) : ViewModel() { - val textFieldState = TextFieldState() private val _isReplying = MutableStateFlow(false) @@ -91,69 +90,75 @@ class ChatInputViewModel( 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 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 - } + 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.suggestions, - ) { (text, cursorPos), channel, enabled -> - Triple(text, cursorPos, channel) to enabled - }.flatMapLatest { (triple, enabled) -> - val (text, cursorPos, channel) = triple - when { - enabled -> suggestionProvider.getSuggestions(text, cursorPos, channel) - else -> flowOf(emptyList()) - } - }.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 val suggestions: StateFlow> = + combine( + debouncedTextAndCursor, + chatChannelProvider.activeChannel, + chatSettingsDataStore.suggestions, + ) { (text, cursorPos), channel, enabled -> + Triple(text, cursorPos, channel) to enabled + }.flatMapLatest { (triple, enabled) -> + val (text, cursorPos, channel) = triple + when { + enabled -> suggestionProvider.getSuggestions(text, cursorPos, channel) + else -> flowOf(emptyList()) + } + }.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 @@ -193,7 +198,10 @@ class ChatInputViewModel( } } - fun uiState(externalSheetState: StateFlow, externalMentionTab: StateFlow): StateFlow { + fun uiState( + externalSheetState: StateFlow, + externalMentionTab: StateFlow, + ): StateFlow { _uiState?.let { return it } // Wire up external sheet state for whisper clearing @@ -206,49 +214,52 @@ class ChatInputViewModel( } } - val baseFlow = combine( - textFlow, - suggestions, - chatChannelProvider.activeChannel, - chatChannelProvider.activeChannel.flatMapLatest { channel -> - if (channel == null) { - flowOf(ConnectionState.DISCONNECTED) - } else { - chatConnector.getConnectionState(channel) - } - }, - combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b }, - ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, autoDisableInput) -> - UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput) - } + val baseFlow = + combine( + textFlow, + suggestions, + chatChannelProvider.activeChannel, + chatChannelProvider.activeChannel.flatMapLatest { channel -> + if (channel == null) { + flowOf(ConnectionState.DISCONNECTED) + } else { + chatConnector.getConnectionState(channel) + } + }, + combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b }, + ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, autoDisableInput) -> + UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput) + } - val replyStateFlow = combine( - _isReplying, - _replyName, - _replyMessageId, - ) { isReplying, replyName, replyMessageId -> - Triple(isReplying, replyName, replyMessageId) - } + val replyStateFlow = + combine( + _isReplying, + _replyName, + _replyMessageId, + ) { isReplying, replyName, replyMessageId -> + Triple(isReplying, replyName, replyMessageId) + } - val inputOverlayFlow = combine( - externalSheetState, - externalMentionTab, - replyStateFlow, - _isEmoteMenuOpen, - _whisperTarget, - _isAnnouncing, - ) { values -> - val sheetState = values[0] as FullScreenSheetState - val tab = values[1] as Int - - @Suppress("UNCHECKED_CAST") - val replyState = values[2] as Triple - val isEmoteMenuOpen = values[3] as Boolean - val whisperTarget = values[4] as UserName? - val isAnnouncing = values[5] as Boolean - val (isReplying, replyName, replyMessageId) = replyState - InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget, isAnnouncing) - } + val inputOverlayFlow = + combine( + externalSheetState, + externalMentionTab, + replyStateFlow, + _isEmoteMenuOpen, + _whisperTarget, + _isAnnouncing, + ) { values -> + val sheetState = values[0] as FullScreenSheetState + val tab = values[1] as Int + + @Suppress("UNCHECKED_CAST") + val replyState = values[2] as Triple + val isEmoteMenuOpen = values[3] as Boolean + val whisperTarget = values[4] as UserName? + val isAnnouncing = values[5] as Boolean + val (isReplying, replyName, replyMessageId) = replyState + InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget, isAnnouncing) + } return combine( baseFlow, @@ -263,43 +274,53 @@ class ChatInputViewModel( val effectiveIsReplying = overlayState.isReplying || isInReplyThread val canTypeInConnectionState = deps.connectionState == ConnectionState.CONNECTED || !deps.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 - } + 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.CONNECTED_NOT_LOGGED_IN -> { + InputState.NotLoggedIn + } - ConnectionState.DISCONNECTED -> InputState.Disconnected - } + ConnectionState.DISCONNECTED -> { + InputState.Disconnected + } + } - val enabled = when { - isMentionsTabActive -> false - isWhisperTabActive -> deps.isLoggedIn && canTypeInConnectionState && overlayState.whisperTarget != null - else -> deps.isLoggedIn && canTypeInConnectionState - } + 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 overlay = when { - overlayState.isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName) - isWhisperTabActive && overlayState.whisperTarget != null -> InputOverlay.Whisper(overlayState.whisperTarget) - overlayState.isAnnouncing -> InputOverlay.Announce - else -> InputOverlay.None - } + val overlay = + when { + overlayState.isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName) + 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 - }, + hasLastMessage = + when { + isWhisperTabActive -> lastWhisperText != null + else -> chatRepository.getLastMessage() != null + }, suggestions = deps.suggestions.toImmutableList(), activeChannel = deps.activeChannel, connectionState = deps.connectionState, @@ -310,10 +331,11 @@ class ChatInputViewModel( isEmoteMenuOpen = overlayState.isEmoteMenuOpen, helperText = helperText, isWhisperTabActive = isWhisperTabActive, - characterCounter = CharacterCounterState.Visible( - text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", - isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, - ), + characterCounter = + CharacterCounterState.Visible( + text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", + isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, + ), userLongClickBehavior = userLongClickBehavior, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()).also { _uiState = it } @@ -324,11 +346,12 @@ class ChatInputViewModel( 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 - } + 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 @@ -338,35 +361,44 @@ class ChatInputViewModel( } } - fun trySendMessageOrCommand(message: String, skipSuspendingCommands: Boolean = false) = viewModelScope.launch { + 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 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) + 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) + 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 } - }.getOrElse { - mainEventBus.emitEvent(MainEvent.Error(it)) - return@launch - } when (commandResult) { is CommandResult.Accepted, is CommandResult.Blocked, - -> Unit + -> { + Unit + } is CommandResult.IrcCommand -> { chatRepository.sendMessage(message, replyIdOrNull, forceIrc = true) @@ -382,14 +414,17 @@ class ChatInputViewModel( if (commandResult.command == TwitchCommand.Whisper) { chatRepository.fakeWhisperIfNecessary(message) } - val isWhisperContext = chatState is FullScreenSheetState.Whisper || - (chatState is FullScreenSheetState.Mention && _whisperTarget.value != null) + 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.AcceptedWithResponse -> { + chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) + } is CommandResult.Message -> { chatRepository.sendMessage(commandResult.message, replyIdOrNull) @@ -403,10 +438,11 @@ class ChatInputViewModel( } fun getLastMessage() { - val message = when { - _whisperTarget.value != null -> lastWhisperText - else -> chatRepository.getLastMessage() - } ?: return + val message = + when { + _whisperTarget.value != null -> lastWhisperText + else -> chatRepository.getLastMessage() + } ?: return textFieldState.edit { replace(0, length, message) placeCursorAtEnd() @@ -420,7 +456,11 @@ class ChatInputViewModel( } } - fun setReplying(replying: Boolean, replyMessageId: String? = null, replyName: UserName? = null) { + fun setReplying( + replying: Boolean, + replyMessageId: String? = null, + replyName: UserName? = null, + ) { _isReplying.value = replying || replyMessageId != null _replyMessageId.value = replyMessageId _replyName.value = replyName @@ -437,7 +477,10 @@ class ChatInputViewModel( } } - fun mentionUser(user: UserName, display: DisplayName) { + fun mentionUser( + user: UserName, + display: DisplayName, + ) { val template = notificationsSettingsDataStore.current().mentionFormat.template val mention = "${template.replace("name", user.valueOrDisplayName(display))} " insertText(mention) @@ -504,9 +547,18 @@ class ChatInputViewModel( } } -internal data class SuggestionReplacementResult(val replaceStart: Int, val replaceEnd: Int, val replacement: String, val newCursorPos: Int) +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 { +internal fun computeSuggestionReplacement( + text: String, + cursorPos: Int, + suggestionText: String, +): SuggestionReplacementResult { val separator = ' ' // Only look backwards from cursor — match what extractCurrentWord does 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 index 7a88a905c..49e16cbb6 100644 --- 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 @@ -49,7 +49,12 @@ private const val MAX_INPUT_ACTIONS = 4 @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun InputActionConfigSheet(inputActions: ImmutableList, debugMode: Boolean, onInputActionsChange: (ImmutableList) -> Unit, onDismiss: () -> Unit) { +internal fun InputActionConfigSheet( + inputActions: ImmutableList, + debugMode: Boolean, + onInputActionsChange: (ImmutableList) -> Unit, + onDismiss: () -> Unit, +) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val localEnabled = remember { mutableStateListOf(*inputActions.toTypedArray()) } @@ -66,9 +71,10 @@ internal fun InputActionConfigSheet(inputActions: ImmutableList, de containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), ) { Text( text = if (atLimit) pluralStringResource(R.plurals.input_actions_max, MAX_INPUT_ACTIONS, MAX_INPUT_ACTIONS) else "", @@ -92,11 +98,12 @@ internal fun InputActionConfigSheet(inputActions: ImmutableList, de color = if (isDragging) MaterialTheme.colorScheme.surfaceContainerHighest else Color.Transparent, ) { Row( - modifier = Modifier - .fillMaxWidth() - .longPressDraggableHandle() - .padding(horizontal = 16.dp, vertical = 8.dp) - .height(40.dp), + modifier = + Modifier + .fillMaxWidth() + .longPressDraggableHandle() + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(40.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -133,17 +140,17 @@ internal fun InputActionConfigSheet(inputActions: ImmutableList, de 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), + 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)) @@ -152,21 +159,23 @@ internal fun InputActionConfigSheet(inputActions: ImmutableList, de imageVector = action.icon, contentDescription = null, modifier = Modifier.size(24.dp), - tint = if (actionEnabled) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) - }, + 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) - }, + color = + if (actionEnabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, ) Checkbox( checked = false, @@ -180,23 +189,25 @@ internal fun InputActionConfigSheet(inputActions: ImmutableList, de } 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 - } + 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.Default.Videocam - InputAction.ModActions -> Icons.Default.Shield - InputAction.Fullscreen -> Icons.Default.Fullscreen - InputAction.HideInput -> Icons.Default.VisibilityOff - InputAction.Debug -> Icons.Default.BugReport - } + get() = + when (this) { + InputAction.Search -> Icons.Default.Search + InputAction.LastMessage -> Icons.Default.History + InputAction.Stream -> Icons.Default.Videocam + InputAction.ModActions -> Icons.Default.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 index e37a8eff3..d766455b5 100644 --- 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 @@ -42,28 +42,38 @@ import com.flxrs.dankchat.ui.chat.suggestion.Suggestion import kotlinx.collections.immutable.ImmutableList @Composable -fun SuggestionDropdown(suggestions: ImmutableList, onSuggestionClick: (Suggestion) -> Unit, modifier: Modifier = Modifier) { +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), + 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) { @@ -71,17 +81,19 @@ fun SuggestionDropdown(suggestions: ImmutableList, onSuggestionClick } OutlinedCard( - modifier = Modifier - .padding(horizontal = 2.dp) - .fillMaxWidth(0.66f) - .heightIn(max = 250.dp), + modifier = + Modifier + .padding(horizontal = 2.dp) + .fillMaxWidth(0.66f) + .heightIn(max = 250.dp), elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), ) { LazyColumn( state = listState, - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(), ) { items(suggestions, key = { it.toString() }) { suggestion -> SuggestionItem( @@ -95,12 +107,17 @@ fun SuggestionDropdown(suggestions: ImmutableList, onSuggestionClick } @Composable -private fun SuggestionItem(suggestion: Suggestion, onClick: () -> Unit, modifier: Modifier = Modifier) { +private fun SuggestionItem( + suggestion: Suggestion, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { // Icon/Image based on suggestion type @@ -109,9 +126,10 @@ private fun SuggestionItem(suggestion: Suggestion, onClick: () -> Unit, modifier AsyncImage( model = suggestion.emote.url, contentDescription = suggestion.emote.code, - modifier = Modifier - .size(48.dp) - .padding(end = 12.dp), + modifier = + Modifier + .size(48.dp) + .padding(end = 12.dp), ) Text( text = suggestion.emote.code, @@ -123,9 +141,10 @@ private fun SuggestionItem(suggestion: Suggestion, onClick: () -> Unit, modifier Icon( imageVector = Icons.Default.Person, contentDescription = null, - modifier = Modifier - .size(32.dp) - .padding(end = 12.dp), + modifier = + Modifier + .size(32.dp) + .padding(end = 12.dp), ) Text( text = suggestion.name.value, @@ -137,10 +156,11 @@ private fun SuggestionItem(suggestion: Suggestion, onClick: () -> Unit, modifier Text( text = suggestion.emoji.unicode, fontSize = 24.sp, - modifier = Modifier - .size(32.dp) - .padding(end = 12.dp) - .wrapContentSize(), + modifier = + Modifier + .size(32.dp) + .padding(end = 12.dp) + .wrapContentSize(), ) Text( text = ":${suggestion.emoji.code}:", @@ -152,9 +172,10 @@ private fun SuggestionItem(suggestion: Suggestion, onClick: () -> Unit, modifier Icon( imageVector = Icons.Default.Android, contentDescription = null, - modifier = Modifier - .size(32.dp) - .padding(end = 12.dp), + modifier = + Modifier + .size(32.dp) + .padding(end = 12.dp), ) Text( text = suggestion.command, @@ -166,9 +187,10 @@ private fun SuggestionItem(suggestion: Suggestion, onClick: () -> Unit, modifier Icon( imageVector = Icons.Default.FilterList, contentDescription = null, - modifier = Modifier - .size(32.dp) - .padding(end = 12.dp), + modifier = + Modifier + .size(32.dp) + .padding(end = 12.dp), ) Column { Text( 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 index 47198c8ed..8728e62be 100644 --- 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 @@ -35,7 +35,14 @@ data class TourOverlayState( @Suppress("ContentSlotReused") @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun OptionalTourTooltip(tooltipState: TooltipState?, text: String, onAdvance: (() -> Unit)?, onSkip: (() -> Unit)?, focusable: Boolean = false, content: @Composable () -> Unit) { +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), @@ -60,13 +67,19 @@ internal fun OptionalTourTooltip(tooltipState: TooltipState?, text: String, onAd @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun TooltipScope.TourTooltip(text: String, onAction: () -> Unit, onSkip: () -> Unit, isLast: Boolean = false) { - val tourColors = TooltipDefaults.richTooltipColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - titleContentColor = MaterialTheme.colorScheme.onSecondaryContainer, - actionContentColor = MaterialTheme.colorScheme.secondary, - ) +internal fun TooltipScope.TourTooltip( + text: String, + onAction: () -> Unit, + onSkip: () -> Unit, + isLast: Boolean = false, +) { + 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)), 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 index 88b489cbc..cb1580967 100644 --- 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 @@ -36,7 +36,11 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DebugInfoSheet(viewModel: DebugInfoViewModel, sheetState: SheetState, onDismiss: () -> Unit) { +fun DebugInfoSheet( + viewModel: DebugInfoViewModel, + sheetState: SheetState, + onDismiss: () -> Unit, +) { val sections by viewModel.sections.collectAsStateWithLifecycle() ModalBottomSheet( @@ -47,10 +51,11 @@ fun DebugInfoSheet(viewModel: DebugInfoViewModel, sheetState: SheetState, onDism ) { val navBarPadding = WindowInsets.navigationBars.asPaddingValues() LazyColumn( - modifier = Modifier - .fillMaxWidth() - .nestedScroll(BottomSheetNestedScrollConnection) - .padding(horizontal = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .nestedScroll(BottomSheetNestedScrollConnection) + .padding(horizontal = 16.dp), contentPadding = navBarPadding, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -82,19 +87,25 @@ fun DebugInfoSheet(viewModel: DebugInfoViewModel, sheetState: SheetState, onDism 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))) + val copyModifier = + when { + entry.copyValue != null -> { + Modifier.clickable { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText(entry.label, entry.copyValue))) + } + } } - } - else -> Modifier - } + else -> { + Modifier + } + } Row( - modifier = copyModifier - .fillMaxWidth() - .padding(vertical = 2.dp), + modifier = + copyModifier + .fillMaxWidth() + .padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( 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 index d0341a686..a8d2a6b8d 100644 --- 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 @@ -14,7 +14,9 @@ import kotlinx.coroutines.flow.stateIn import org.koin.android.annotation.KoinViewModel @KoinViewModel -class DebugInfoViewModel(debugSectionRegistry: DebugSectionRegistry) : ViewModel() { +class DebugInfoViewModel( + debugSectionRegistry: DebugSectionRegistry, +) : ViewModel() { val sections: StateFlow> = debugSectionRegistry .allSections() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt index 381e6814c..648521b8e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt @@ -50,16 +50,26 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmoteMenu(onEmoteClick: (String, String) -> Unit, onBackspace: () -> Unit, modifier: Modifier = Modifier, viewModel: EmoteMenuViewModel = koinViewModel()) { +fun EmoteMenu( + onEmoteClick: (String, String) -> Unit, + onBackspace: () -> Unit, + modifier: Modifier = Modifier, + viewModel: EmoteMenuViewModel = koinViewModel(), +) { val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - val pagerState = rememberPagerState( - initialPage = 0, - pageCount = { tabItems.size }, - ) + val pagerState = + rememberPagerState( + initialPage = 0, + pageCount = { tabItems.size }, + ) val subsGridState = rememberLazyGridState() - val subsFirstHeader = tabItems.getOrNull(EmoteMenuTab.SUBS.ordinal) - ?.items?.firstOrNull()?.let { (it as? EmoteItem.Header)?.title } + val subsFirstHeader = + tabItems + .getOrNull(EmoteMenuTab.SUBS.ordinal) + ?.items + ?.firstOrNull() + ?.let { (it as? EmoteItem.Header)?.title } LaunchedEffect(subsFirstHeader) { subsGridState.scrollToItem(0) @@ -80,12 +90,13 @@ fun EmoteMenu(onEmoteClick: (String, String) -> Unit, onBackspace: () -> Unit, m 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) - }, + 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) + }, ) }, ) @@ -150,9 +161,10 @@ fun EmoteMenu(onEmoteClick: (String, String) -> Unit, onBackspace: () -> Unit, m Text( text = item.title, style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) } @@ -160,10 +172,11 @@ fun EmoteMenu(onEmoteClick: (String, String) -> Unit, onBackspace: () -> Unit, m AsyncImage( model = item.emote.url, contentDescription = item.emote.code, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clickable { onEmoteClick(item.emote.code, item.emote.id) }, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable { onEmoteClick(item.emote.code, item.emote.id) }, ) } } @@ -175,13 +188,15 @@ fun EmoteMenu(onEmoteClick: (String, String) -> Unit, onBackspace: () -> Unit, m // 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), + 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, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt index d139b62ee..4151e4cdc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt @@ -38,13 +38,19 @@ import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmoteMenuSheet(onDismiss: () -> Unit, onEmoteClick: (String, String) -> Unit, sheetState: SheetState, viewModel: EmoteMenuViewModel = koinViewModel()) { +fun EmoteMenuSheet( + onDismiss: () -> Unit, + onEmoteClick: (String, String) -> Unit, + sheetState: SheetState, + viewModel: EmoteMenuViewModel = koinViewModel(), +) { val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - val pagerState = rememberPagerState( - initialPage = 0, - pageCount = { tabItems.size }, - ) + val pagerState = + rememberPagerState( + initialPage = 0, + pageCount = { tabItems.size }, + ) ModalBottomSheet( onDismissRequest = onDismiss, @@ -60,12 +66,13 @@ fun EmoteMenuSheet(onDismiss: () -> Unit, onEmoteClick: (String, String) -> Unit 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) - }, + 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) + }, ) }, ) @@ -111,9 +118,10 @@ fun EmoteMenuSheet(onDismiss: () -> Unit, onEmoteClick: (String, String) -> Unit Text( text = item.title, style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) } @@ -121,10 +129,11 @@ fun EmoteMenuSheet(onDismiss: () -> Unit, onEmoteClick: (String, String) -> Unit AsyncImage( model = item.emote.url, contentDescription = item.emote.code, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clickable { onEmoteClick(item.emote.code, item.emote.id) }, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable { onEmoteClick(item.emote.code, item.emote.id) }, ) } } 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 index df34a3f50..d8d6fd4b5 100644 --- 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 @@ -26,7 +26,11 @@ import kotlinx.coroutines.withContext import org.koin.android.annotation.KoinViewModel @KoinViewModel -class EmoteMenuViewModel(private val chatChannelProvider: ChatChannelProvider, private val dataRepository: DataRepository, private val emoteUsageRepository: EmoteUsageRepository) : ViewModel() { +class EmoteMenuViewModel( + private val chatChannelProvider: ChatChannelProvider, + private val dataRepository: DataRepository, + private val emoteUsageRepository: EmoteUsageRepository, +) : ViewModel() { private val activeChannel = chatChannelProvider.activeChannel private val emotes = 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 index d6ab8c7a8..4187968f6 100644 --- 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 @@ -65,10 +65,11 @@ fun FullScreenSheetOverlay( } val mentionableClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, isLongPress -> - val shouldOpenPopup = when (userLongClickBehavior) { - UserLongClickBehavior.MentionsUser -> !isLongPress - UserLongClickBehavior.OpensPopup -> isLongPress - } + val shouldOpenPopup = + when (userLongClickBehavior) { + UserLongClickBehavior.MentionsUser -> !isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress + } if (shouldOpenPopup) { onUserClick( UserPopupStateParams( @@ -85,13 +86,14 @@ fun FullScreenSheetOverlay( } when (sheetState) { - is FullScreenSheetState.Closed -> Unit + is FullScreenSheetState.Closed -> { + Unit + } is FullScreenSheetState.Mention -> { MentionSheet( mentionViewModel = mentionViewModel, initialisWhisperTab = false, - onDismiss = onDismiss, onUserClick = popupOnlyClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> @@ -117,7 +119,6 @@ fun FullScreenSheetOverlay( MentionSheet( mentionViewModel = mentionViewModel, initialisWhisperTab = true, - onDismiss = onDismiss, onUserClick = popupOnlyClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> @@ -142,7 +143,6 @@ fun FullScreenSheetOverlay( is FullScreenSheetState.Replies -> { RepliesSheet( rootMessageId = sheetState.replyMessageId, - onDismiss = onDismissReplies, onUserClick = mentionableClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> @@ -163,15 +163,17 @@ fun FullScreenSheetOverlay( } is FullScreenSheetState.History -> { - val viewModel: MessageHistoryViewModel = koinViewModel( - key = "history-${sheetState.channel.value}", - parameters = { parametersOf(sheetState.channel) }, - ) + val viewModel: MessageHistoryViewModel = + koinViewModel( + key = "history-${sheetState.channel.value}", + parameters = { parametersOf(sheetState.channel) }, + ) val historyClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, isLongPress -> - val shouldOpenPopup = when (userLongClickBehavior) { - UserLongClickBehavior.MentionsUser -> !isLongPress - UserLongClickBehavior.OpensPopup -> isLongPress - } + val shouldOpenPopup = + when (userLongClickBehavior) { + UserLongClickBehavior.MentionsUser -> !isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress + } if (shouldOpenPopup) { onUserClick( UserPopupStateParams( @@ -190,7 +192,6 @@ fun FullScreenSheetOverlay( viewModel = viewModel, channel = sheetState.channel, initialFilter = sheetState.initialFilter, - onDismiss = onDismiss, onUserClick = historyClickHandler, onMessageLongClick = { messageId, channel, fullMessage -> 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 index 6acfb42e8..3729097f8 100644 --- 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 @@ -69,29 +69,32 @@ fun MentionSheet( ) { val scope = rememberCoroutineScope() val density = LocalDensity.current - val pagerState = rememberPagerState( - initialPage = if (initialisWhisperTab) 1 else 0, - pageCount = { 2 }, - ) + 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 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) LaunchedEffect(pagerState.currentPage) { @@ -110,16 +113,17 @@ fun MentionSheet( } Box( - modifier = Modifier - .fillMaxSize() - .background(sheetBackgroundColor) - .graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - translationY = backProgress * 100f - }, + 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, @@ -147,18 +151,19 @@ fun MentionSheet( 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), + 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(), @@ -185,16 +190,18 @@ fun MentionSheet( 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 - } + 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), + modifier = + Modifier + .clickable { scope.launch { pagerState.animateScrollToPage(index) } } + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 16.dp), ) { Text( text = stringResource(stringRes), @@ -211,11 +218,12 @@ fun MentionSheet( if (!toolbarVisible) { Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)), + 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 index 266043b5c..65ba3438d 100644 --- 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 @@ -93,11 +93,12 @@ fun MessageHistorySheet( val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) val filterSuggestions by viewModel.filterSuggestions.collectAsStateWithLifecycle() - val sheetBackgroundColor = lerp( - MaterialTheme.colorScheme.surfaceContainer, - MaterialTheme.colorScheme.surfaceContainerHigh, - fraction = 0.75f, - ) + val sheetBackgroundColor = + lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) var backProgress by remember { mutableFloatStateOf(0f) } val density = LocalDensity.current @@ -125,40 +126,43 @@ fun MessageHistorySheet( } 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 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 context = LocalPlatformContext.current val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) Box( - modifier = Modifier - .fillMaxSize() - .background(sheetBackgroundColor) - .graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - translationY = backProgress * 100f - }, + modifier = + Modifier + .fillMaxSize() + .background(sheetBackgroundColor) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + }, ) { CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { ChatScreen( messages = messages, fontSize = displaySettings.fontSize, - callbacks = ChatScreenCallbacks( - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - ), + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ), showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = Modifier.fillMaxSize(), @@ -176,18 +180,19 @@ fun MessageHistorySheet( 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), + 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(), @@ -212,9 +217,10 @@ fun MessageHistorySheet( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .defaultMinSize(minHeight = 48.dp) - .padding(horizontal = 16.dp), + modifier = + Modifier + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 16.dp), ) { Text( text = stringResource(R.string.message_history_title, channel.value), @@ -230,35 +236,37 @@ fun MessageHistorySheet( // Filter suggestions above search bar if (!toolbarVisible) { Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)), + 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), + 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() - .onGloballyPositioned { coordinates -> - searchBarHeightPx = coordinates.size.height - } - .padding(bottom = 8.dp) - .padding(horizontal = 8.dp), + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = currentImeDp) + .navigationBarsPadding() + .onGloballyPositioned { coordinates -> + searchBarHeightPx = coordinates.size.height + }.padding(bottom = 8.dp) + .padding(horizontal = 8.dp), ) { SearchToolbar( state = viewModel.searchFieldState, @@ -271,12 +279,13 @@ fun MessageHistorySheet( 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, - ) + val textFieldColors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ) TextField( state = state, 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 index 8713e6d6a..80299f925 100644 --- 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 @@ -56,30 +56,33 @@ fun RepliesSheet( onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, bottomContentPadding: Dp = 0.dp, ) { - val viewModel: RepliesViewModel = koinViewModel( - key = rootMessageId, - parameters = { parametersOf(rootMessageId) }, - ) + 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 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 -> @@ -94,16 +97,17 @@ fun RepliesSheet( } Box( - modifier = Modifier - .fillMaxSize() - .background(sheetBackgroundColor) - .graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - translationY = backProgress * 100f - }, + 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, @@ -124,18 +128,19 @@ fun RepliesSheet( 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), + 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(), @@ -171,11 +176,12 @@ fun RepliesSheet( if (!toolbarVisible) { Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)), + 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/SheetNavigationState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationState.kt index e3df16a86..fc2d8b93a 100644 --- 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 @@ -7,13 +7,19 @@ import com.flxrs.dankchat.data.UserName sealed interface FullScreenSheetState { data object Closed : FullScreenSheetState - data class Replies(val replyMessageId: String, val replyName: UserName) : FullScreenSheetState + data class Replies( + val replyMessageId: String, + val replyName: UserName, + ) : FullScreenSheetState data object Mention : FullScreenSheetState data object Whisper : FullScreenSheetState - data class History(val channel: UserName, val initialFilter: String = "") : FullScreenSheetState + data class History( + val channel: UserName, + val initialFilter: String = "", + ) : FullScreenSheetState } @Immutable @@ -26,4 +32,7 @@ sealed interface InputSheetState { } @Immutable -data class SheetNavigationState(val fullScreenSheet: FullScreenSheetState = FullScreenSheetState.Closed, val inputSheet: InputSheetState = InputSheetState.Closed) +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 index 748e0c31a..a100eeb12 100644 --- 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 @@ -13,20 +13,23 @@ import org.koin.android.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) { + 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) } @@ -38,7 +41,10 @@ class SheetNavigationViewModel : ViewModel() { _fullScreenSheetState.value = FullScreenSheetState.Whisper } - fun openHistory(channel: UserName, initialFilter: String = "") { + fun openHistory( + channel: UserName, + initialFilter: String = "", + ) { _fullScreenSheetState.value = FullScreenSheetState.History(channel, initialFilter) } @@ -58,17 +64,20 @@ class SheetNavigationViewModel : ViewModel() { _inputSheetState.value = InputSheetState.Closed } - fun handleBackPress(): Boolean = when { - _inputSheetState.value != InputSheetState.Closed -> { - closeInputSheet() - true - } - - _fullScreenSheetState.value != FullScreenSheetState.Closed -> { - closeFullScreenSheet() - true + fun handleBackPress(): Boolean = + when { + _inputSheetState.value != InputSheetState.Closed -> { + closeInputSheet() + true + } + + _fullScreenSheetState.value != FullScreenSheetState.Closed -> { + closeFullScreenSheet() + true + } + + else -> { + false + } } - - else -> false - } } 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 index 04626efa4..20230049b 100644 --- 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 @@ -37,20 +37,29 @@ import com.flxrs.dankchat.data.UserName @Suppress("LambdaParameterEventTrailing") @Composable -fun StreamView(channel: UserName, streamViewModel: StreamViewModel, modifier: Modifier = Modifier, isInPipMode: Boolean = false, fillPane: Boolean = false, onClose: () -> Unit) { +fun StreamView( + channel: UserName, + streamViewModel: StreamViewModel, + modifier: Modifier = Modifier, + isInPipMode: Boolean = false, + fillPane: Boolean = false, + onClose: () -> Unit, +) { // 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) } - val webView = remember { - streamViewModel.getOrCreateWebView().also { wv -> - wv.setBackgroundColor(android.graphics.Color.TRANSPARENT) - wv.webViewClient = StreamComposeWebViewClient( - onPageFinished = { isPageLoaded = true }, - ) + val webView = + remember { + streamViewModel.getOrCreateWebView().also { wv -> + wv.setBackgroundColor(android.graphics.Color.TRANSPARENT) + wv.webViewClient = + StreamComposeWebViewClient( + onPageFinished = { isPageLoaded = true }, + ) + } } - } // For first open: load URL on detached WebView if (!hasBeenAttached) { @@ -72,28 +81,34 @@ fun StreamView(channel: UserName, streamViewModel: StreamViewModel, modifier: Mo } Box( - modifier = modifier - .then(if (isInPipMode || fillPane) Modifier else Modifier.statusBarsPadding()) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface), + modifier = + modifier + .then(if (isInPipMode || fillPane) Modifier else Modifier.statusBarsPadding()) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface), ) { - val webViewModifier = when { - isInPipMode || fillPane -> Modifier.fillMaxSize() + val webViewModifier = + when { + isInPipMode || fillPane -> { + Modifier.fillMaxSize() + } - else -> - Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - } + 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, - ) + webView.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) if (!hasBeenAttached) { hasBeenAttached = true streamViewModel.hasWebViewBeenAttached = true @@ -111,9 +126,10 @@ fun StreamView(channel: UserName, streamViewModel: StreamViewModel, modifier: Mo update = { _ -> streamViewModel.setStream(channel, webView) }, - modifier = webViewModifier.graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - }, + modifier = + webViewModifier.graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + }, ) } else { Box(modifier = webViewModifier) @@ -122,16 +138,16 @@ fun StreamView(channel: UserName, streamViewModel: StreamViewModel, modifier: Mo if (!isInPipMode) { Box( contentAlignment = Alignment.Center, - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .padding(8.dp) - .size(28.dp) - .background( - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f), - shape = CircleShape, - ) - .clickable(onClick = onClose), + modifier = + Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(8.dp) + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f), + shape = CircleShape, + ).clickable(onClick = onClose), ) { Icon( imageVector = Icons.Default.Close, @@ -144,21 +160,31 @@ fun StreamView(channel: UserName, streamViewModel: StreamViewModel, modifier: Mo } } -private class StreamComposeWebViewClient(private val onPageFinished: () -> Unit) : WebViewClient() { - - override fun onPageFinished(view: WebView?, url: String?) { +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 { + 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 { + 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) } @@ -166,11 +192,12 @@ private class StreamComposeWebViewClient(private val onPageFinished: () -> Unit) 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/", - ) + 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 index c1e2e6e74..d82b541b5 100644 --- 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 @@ -26,30 +26,32 @@ class StreamViewModel( 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) - - val streamState: StateFlow = combine( - _currentStreamedChannel, - hasStreamData, - ) { currentStream, hasData -> - StreamState(currentStream = currentStream, hasStreamData = hasData) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StreamState()) - - val shouldEnablePipAutoMode: StateFlow = combine( - _currentStreamedChannel, - streamsSettingsDataStore.pipEnabled, - ) { currentStream, pipEnabled -> - currentStream != null && pipEnabled - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + 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) + + val streamState: StateFlow = + combine( + _currentStreamedChannel, + hasStreamData, + ) { currentStream, hasData -> + StreamState(currentStream = currentStream, hasStreamData = hasData) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StreamState()) + + val shouldEnablePipAutoMode: StateFlow = + combine( + _currentStreamedChannel, + streamsSettingsDataStore.pipEnabled, + ) { currentStream, pipEnabled -> + currentStream != null && pipEnabled + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { viewModelScope.launch { @@ -76,7 +78,10 @@ class StreamViewModel( } } - fun setStream(channel: UserName, webView: StreamWebView) { + fun setStream( + channel: UserName, + webView: StreamWebView, + ) { if (channel == lastStreamedChannel) return lastStreamedChannel = channel loadStream(channel, webView) @@ -92,7 +97,10 @@ class StreamViewModel( hasWebViewBeenAttached = false } - private fun loadStream(channel: UserName, webView: StreamWebView) { + 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) @@ -116,4 +124,7 @@ class StreamViewModel( } @Immutable -data class StreamState(val currentStream: UserName? = null, val hasStreamData: Boolean = false) +data class StreamState( + val currentStream: UserName? = null, + val hasStreamData: 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 index 59e218258..6cdbfa99d 100644 --- 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 @@ -11,60 +11,70 @@ 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 + @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() } - 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 - } + 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) - } + stopLoading() + loadUrl(url) + } - private class StreamWebViewClient : WebViewClient() { - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { - if (url.isNullOrBlank()) { - return true + 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) } } - 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 + } - 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) } } - - 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/", - ) + 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 index 61663a136..3b4ae17e1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt @@ -15,19 +15,24 @@ import kotlinx.coroutines.runBlocking import org.koin.core.annotation.Single @Single -class OnboardingDataStore(context: Context, dispatchersProvider: DispatchersProvider, dankChatPreferenceStore: DankChatPreferenceStore) { +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 migrate(currentData: OnboardingSettings): OnboardingSettings = + currentData.copy( + hasCompletedOnboarding = true, + hasRunExistingUserMigration = true, + hasShownAddChannelHint = true, + hasShownToolbarHint = true, + ) override suspend fun cleanUp() = Unit } 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 index 0294018cf..54132bce3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt @@ -67,14 +67,19 @@ import org.koin.compose.viewmodel.koinViewModel private const val PAGE_COUNT = 4 @Composable -fun OnboardingScreen(onNavigateToLogin: () -> Unit, onComplete: () -> Unit, modifier: Modifier = Modifier) { +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 }, - ) + val pagerState = + rememberPagerState( + initialPage = state.initialPage, + pageCount = { PAGE_COUNT }, + ) LaunchedEffect(pagerState.currentPage) { viewModel.setCurrentPage(pagerState.currentPage) } @@ -88,16 +93,18 @@ fun OnboardingScreen(onNavigateToLogin: () -> Unit, onComplete: () -> Unit, modi Surface(modifier = modifier.fillMaxSize()) { Column( - modifier = Modifier - .fillMaxSize() - .safeDrawingPadding() - .padding(horizontal = 24.dp), + modifier = + Modifier + .fillMaxSize() + .safeDrawingPadding() + .padding(horizontal = 24.dp), ) { LinearProgressIndicator( progress = { (pagerState.currentPage + 1).toFloat() / PAGE_COUNT }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), ) HorizontalPager( @@ -106,34 +113,42 @@ fun OnboardingScreen(onNavigateToLogin: () -> Unit, onComplete: () -> Unit, modi modifier = Modifier.weight(1f), ) { page -> when (page) { - 0 -> WelcomePage( - onStart = { scope.launch { pagerState.animateScrollToPage(1) } }, - ) + 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) } }, - ) + 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) } - }, - ) + 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) - }, - ) + 3 -> { + NotificationsPage( + onContinue = { + viewModel.completeOnboarding(onComplete) + }, + ) + } } } } @@ -141,11 +156,18 @@ fun OnboardingScreen(onNavigateToLogin: () -> Unit, onComplete: () -> Unit, modi } @Composable -private fun OnboardingPage(title: String, icon: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit) { +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), + modifier = + modifier + .fillMaxSize() + .padding(vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -174,7 +196,10 @@ private fun OnboardingBody(text: String) { } @Composable -private fun WelcomePage(onStart: () -> Unit, modifier: Modifier = Modifier) { +private fun WelcomePage( + onStart: () -> Unit, + modifier: Modifier = Modifier, +) { OnboardingPage( icon = { Icon( @@ -195,7 +220,13 @@ private fun WelcomePage(onStart: () -> Unit, modifier: Modifier = Modifier) { } @Composable -private fun LoginPage(loginCompleted: Boolean, onLogin: () -> Unit, onSkip: () -> Unit, onContinue: () -> Unit, modifier: Modifier = Modifier) { +private fun LoginPage( + loginCompleted: Boolean, + onLogin: () -> Unit, + onSkip: () -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { OnboardingPage( icon = { Icon( @@ -260,7 +291,12 @@ private fun LoginPage(loginCompleted: Boolean, onLogin: () -> Unit, onSkip: () - } @Composable -private fun MessageHistoryPage(decided: Boolean, onEnable: () -> Unit, onDisable: () -> Unit, modifier: Modifier = Modifier) { +private fun MessageHistoryPage( + decided: Boolean, + onEnable: () -> Unit, + onDisable: () -> Unit, + modifier: Modifier = Modifier, +) { OnboardingPage( icon = { Icon( @@ -275,22 +311,25 @@ private fun MessageHistoryPage(decided: Boolean, onEnable: () -> Unit, onDisable 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) + 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)) } - append(bodyText.substring(urlStart + url.length)) - } - else -> append(bodyText) + else -> { + append(bodyText) + } + } } } - } Text( text = annotatedBody, style = MaterialTheme.typography.bodyMedium, @@ -317,29 +356,34 @@ private enum class NotificationPermissionState { Pending, Granted, Denied } @SuppressLint("InlinedApi") @Composable -private fun NotificationsPage(onContinue: () -> Unit, modifier: Modifier = Modifier) { +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 + 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 + val granted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED if (granted) { onContinue() } @@ -384,9 +428,10 @@ private fun NotificationsPage(onContinue: () -> Unit, modifier: Modifier = Modif Spacer(modifier = Modifier.height(12.dp)) FilledTonalButton( onClick = { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } + val intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } context.startActivity(intent) }, ) { 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 index 51094eecf..e6e996c5b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt @@ -57,7 +57,6 @@ import org.koin.android.ext.android.inject import java.io.File class ShareUploadActivity : ComponentActivity() { - private val dataRepository: DataRepository by inject() private var uploadState by mutableStateOf(ShareUploadState.Loading) @@ -91,20 +90,21 @@ class ShareUploadActivity : ComponentActivity() { lifecycleScope.launch { uploadState = ShareUploadState.Loading - val file = withContext(Dispatchers.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() + val file = + withContext(Dispatchers.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 } - copy - } catch (_: Throwable) { - null } - } if (file == null) { uploadState = ShareUploadState.Error(getString(R.string.snackbar_upload_failed)) @@ -124,9 +124,10 @@ class ShareUploadActivity : ComponentActivity() { result.fold( onSuccess = { url -> uploadState = ShareUploadState.Success(url) }, onFailure = { error -> - uploadState = ShareUploadState.Error( - error.message ?: getString(R.string.snackbar_upload_failed), - ) + uploadState = + ShareUploadState.Error( + error.message ?: getString(R.string.snackbar_upload_failed), + ) }, ) } @@ -135,12 +136,22 @@ class ShareUploadActivity : ComponentActivity() { @Immutable sealed interface ShareUploadState { data object Loading : ShareUploadState - data class Success(val url: String) : ShareUploadState - data class Error(val message: String) : 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) { +private fun ShareUploadDialog( + state: ShareUploadState, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { Surface( shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh, 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 index ed595b4e9..902bd2bf6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -19,28 +19,36 @@ 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, -) +private val TrueDarkColorScheme = + darkColorScheme( + surface = Color.Black, + background = Color.Black, + onSurface = Color.White, + onBackground = Color.White, + ) /** * Additional color values needed for dynamic text color selection * based on background brightness. */ -data class AdaptiveColors(val onSurfaceLight: Color, val onSurfaceDark: Color) +data class AdaptiveColors( + val onSurfaceLight: Color, + val onSurfaceDark: Color, +) -val LocalAdaptiveColors = staticCompositionLocalOf { - AdaptiveColors( - onSurfaceLight = lightColorScheme().onSurface, - onSurfaceDark = darkColorScheme().onSurface, - ) -} +val LocalAdaptiveColors = + staticCompositionLocalOf { + AdaptiveColors( + onSurfaceLight = lightColorScheme().onSurface, + onSurfaceDark = darkColorScheme().onSurface, + ) + } @Composable -fun DankChatTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { +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 } @@ -48,29 +56,39 @@ fun DankChatTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composab // Dynamic color is available on Android 12+ val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - val lightColorScheme = when { - dynamicColor -> dynamicLightColorScheme(LocalContext.current) - else -> expressiveLightColorScheme() - } - val darkColorScheme = when { - dynamicColor && trueDarkTheme -> dynamicDarkColorScheme(LocalContext.current).copy( - surface = TrueDarkColorScheme.surface, - background = TrueDarkColorScheme.background, - ) + val lightColorScheme = + when { + dynamicColor -> dynamicLightColorScheme(LocalContext.current) + else -> expressiveLightColorScheme() + } + val darkColorScheme = + when { + dynamicColor && trueDarkTheme -> { + dynamicDarkColorScheme(LocalContext.current).copy( + surface = TrueDarkColorScheme.surface, + background = TrueDarkColorScheme.background, + ) + } - dynamicColor -> dynamicDarkColorScheme(LocalContext.current) + dynamicColor -> { + dynamicDarkColorScheme(LocalContext.current) + } - else -> darkColorScheme() - } + else -> { + darkColorScheme() + } + } - val adaptiveColors = AdaptiveColors( - onSurfaceLight = lightColorScheme.onSurface, - onSurfaceDark = darkColorScheme.onSurface, - ) - val colors = when { - darkTheme -> darkColorScheme - else -> lightColorScheme - } + val adaptiveColors = + AdaptiveColors( + onSurfaceLight = lightColorScheme.onSurface, + onSurfaceDark = darkColorScheme.onSurface, + ) + val colors = + when { + darkTheme -> darkColorScheme + else -> lightColorScheme + } MaterialExpressiveTheme( motionScheme = MotionScheme.expressive(), 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 index 9c84969b1..4e0c80b44 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt @@ -55,8 +55,10 @@ data class FeatureTourUiState( @OptIn(ExperimentalMaterial3Api::class) @KoinViewModel -class FeatureTourViewModel(private val onboardingDataStore: OnboardingDataStore, startupValidationHolder: StartupValidationHolder) : ViewModel() { - +class FeatureTourViewModel( + private val onboardingDataStore: OnboardingDataStore, + startupValidationHolder: StartupValidationHolder, +) : ViewModel() { // Material3 tooltip states — UI objects exposed directly, not in the StateFlow. val inputActionsTooltipState = TooltipState(isPersistent = true) val overflowMenuTooltipState = TooltipState(isPersistent = true) @@ -73,44 +75,53 @@ class FeatureTourViewModel(private val onboardingDataStore: OnboardingDataStore, val completed: Boolean = false, ) - private data class ChannelState(val ready: Boolean = false, val empty: Boolean = true) + 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()) + 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) { + fun onChannelsChanged( + empty: Boolean, + ready: Boolean, + ) { _channelState.value = ChannelState(ready = ready, empty = empty) } @@ -141,17 +152,19 @@ class FeatureTourViewModel(private val onboardingDataStore: OnboardingDataStore, 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 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, - ) + _tourState.value = + TourInternalState( + isActive = true, + stepIndex = stepIndex, + forceOverflowOpen = step == TourStep.ConfigureActions, + gestureInputHidden = step == TourStep.RecoveryFab, + ) showTooltipForStep(step) } @@ -170,7 +183,9 @@ class FeatureTourViewModel(private val onboardingDataStore: OnboardingDataStore, val nextIndex = tour.stepIndex + 1 val nextStep = TourStep.entries.getOrNull(nextIndex) when { - nextStep == null -> completeTour() + nextStep == null -> { + completeTour() + } else -> { viewModelScope.launch { @@ -223,13 +238,14 @@ class FeatureTourViewModel(private val onboardingDataStore: OnboardingDataStore, 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 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, @@ -239,25 +255,26 @@ class FeatureTourViewModel(private val onboardingDataStore: OnboardingDataStore, tourActive: Boolean, tourCompleted: Boolean, authValidated: Boolean, - ): PostOnboardingStep = when { - tourCompleted -> PostOnboardingStep.Complete + ): PostOnboardingStep = + when { + tourCompleted -> PostOnboardingStep.Complete - settings.featureTourVersion >= CURRENT_TOUR_VERSION && toolbarHintDone -> PostOnboardingStep.Complete + settings.featureTourVersion >= CURRENT_TOUR_VERSION && toolbarHintDone -> PostOnboardingStep.Complete - !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle + !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle - !authValidated -> PostOnboardingStep.Idle + !authValidated -> PostOnboardingStep.Idle - !channelReady -> PostOnboardingStep.Idle + !channelReady -> PostOnboardingStep.Idle - channelEmpty -> PostOnboardingStep.Idle + channelEmpty -> PostOnboardingStep.Idle - tourActive -> PostOnboardingStep.FeatureTour + tourActive -> PostOnboardingStep.FeatureTour - !toolbarHintDone -> PostOnboardingStep.ToolbarPlusHint + !toolbarHintDone -> PostOnboardingStep.ToolbarPlusHint - // At this point: toolbarHintDone=true but (version >= CURRENT && toolbarHintDone) was false, - // so featureTourVersion < CURRENT_TOUR_VERSION is guaranteed. - else -> PostOnboardingStep.FeatureTour - } + // 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 45e11dafd..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,7 +8,9 @@ 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 @@ -22,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) { @@ -41,10 +45,16 @@ class AppLifecycleListener(val app: Application) { 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 8e395c3f3..f9616d8d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt @@ -7,14 +7,19 @@ import java.time.format.DateTimeFormatter import kotlin.time.Duration.Companion.seconds object DateTimeUtils { - fun timestampToLocalTime(ts: Long, formatter: DateTimeFormatter): String = Instant - .ofEpochMilli(ts) - .atZone(ZoneId.systemDefault()) - .format(formatter) - - fun String.asParsedZonedDateTime(): String = ZonedDateTime - .parse(this) - .format(DateTimeFormatter.ISO_LOCAL_DATE) + fun timestampToLocalTime( + ts: Long, + formatter: DateTimeFormatter, + ): String = + Instant + .ofEpochMilli(ts) + .atZone(ZoneId.systemDefault()) + .format(formatter) + + fun String.asParsedZonedDateTime(): String = + ZonedDateTime + .parse(this) + .format(DateTimeFormatter.ISO_LOCAL_DATE) fun formatSeconds(durationInSeconds: Int): String { val seconds = durationInSeconds % 60 @@ -64,39 +69,45 @@ object DateTimeUtils { return seconds } - private fun secondsMultiplierForUnit(char: Char): Int? = when (char) { - 's' -> 1 - 'm' -> 60 - 'h' -> 60 * 60 - 'd' -> 60 * 60 * 24 - 'w' -> 60 * 60 * 24 * 7 - else -> null - } + private fun secondsMultiplierForUnit(char: Char): Int? = + when (char) { + '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)) - } + 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 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() 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 4ddfa6479..88669471b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt @@ -7,13 +7,21 @@ import android.net.Uri import androidx.activity.result.contract.ActivityResultContract class GetImageOrVideoContract : ActivityResultContract() { - 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 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 { - intent == null || resultCode != Activity.RESULT_OK -> null - else -> intent.data - } + override fun parseResult( + resultCode: Int, + intent: Intent?, + ): Uri? = + when { + intent == null || resultCode != Activity.RESULT_OK -> null + 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 4df184596..230e5bfad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt @@ -6,7 +6,10 @@ 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 b7f8d3e3a..4e8de1bc0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt @@ -47,23 +47,28 @@ private val GPS_ATTRIBUTES = ) @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) } -fun tryClearEmptyFiles(context: Context) = runCatching { - val cutoff = System.currentTimeMillis().milliseconds - 1.days - context - .getExternalFilesDir("Media") - ?.listFiles() - ?.filter { it.isFile && it.lastModified().milliseconds < cutoff } - ?.onEach { it.delete() } -} +fun tryClearEmptyFiles(context: Context) = + runCatching { + val cutoff = System.currentTimeMillis().milliseconds - 1.days + context + .getExternalFilesDir("Media") + ?.listFiles() + ?.filter { it.isFile && it.lastModified().milliseconds < cutoff } + ?.onEach { it.delete() } + } @Throws(IOException::class, IllegalStateException::class) -fun File.removeExifAttributes() = ExifInterface(this).run { - GPS_ATTRIBUTES.forEach { if (getAttribute(it) != null) setAttribute(it, null) } - saveAttributes() -} +fun File.removeExifAttributes() = + ExifInterface(this).run { + GPS_ATTRIBUTES.forEach { if (getAttribute(it) != null) setAttribute(it, null) } + saveAttributes() + } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt index a95707944..4203b56b2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt @@ -12,40 +12,50 @@ import kotlinx.collections.immutable.persistentListOf @Immutable sealed interface TextResource { @Immutable - data class Plain(val value: String) : TextResource + data class Plain( + val value: String, + ) : TextResource @Immutable - data class Res(@param:StringRes val id: Int, val args: ImmutableList = persistentListOf()) : TextResource + 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 + 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 - } +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 + is TextResource.Res -> { + val resolvedArgs = + args.map { arg -> + when (arg) { + is TextResource -> arg.resolve() + else -> arg + } } - } - stringResource(id, *resolvedArgs.toTypedArray()) - } + stringResource(id, *resolvedArgs.toTypedArray()) + } - is TextResource.PluralRes -> { - val resolvedArgs = - args.map { arg -> - when (arg) { - is TextResource -> arg.resolve() - else -> arg + is TextResource.PluralRes -> { + val resolvedArgs = + args.map { arg -> + when (arg) { + is TextResource -> arg.resolve() + else -> arg + } } - } - pluralStringResource(id, quantity, *resolvedArgs.toTypedArray()) + 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 index b219e28c8..fd0dca841 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt @@ -16,10 +16,18 @@ import androidx.compose.ui.unit.Velocity * 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 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) + 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/ContentAlpha.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ContentAlpha.kt index e60cc6883..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 @@ -75,7 +75,10 @@ object ContentAlpha { * for, and under what circumstances. */ @Composable - private fun resolveAlpha(@FloatRange(from = 0.0, to = 1.0) highContrastAlpha: Float, @FloatRange(from = 0.0, to = 1.0) lowContrastAlpha: Float): Float { + private fun resolveAlpha( + @FloatRange(from = 0.0, to = 1.0) highContrastAlpha: Float, + @FloatRange(from = 0.0, to = 1.0) lowContrastAlpha: Float, + ): Float { val contentColor = LocalContentColor.current val isDarkTheme = isSystemInDarkTheme() return if (isDarkTheme) { 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 index d41353a30..167f630d2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt @@ -52,10 +52,11 @@ fun InfoBottomSheet( } else -> { - val sheetState = rememberUnstyledSheetState( - initialDetent = SheetDetent.FullyExpanded, - detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), - ) + val sheetState = + rememberUnstyledSheetState( + initialDetent = SheetDetent.FullyExpanded, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), + ) LaunchedEffect(sheetState.currentDetent) { if (sheetState.currentDetent == SheetDetent.Hidden) { sheetState.jumpTo(SheetDetent.FullyExpanded) @@ -64,9 +65,10 @@ fun InfoBottomSheet( 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)), + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), ) Surface( shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), @@ -75,18 +77,20 @@ fun InfoBottomSheet( modifier = Modifier.fillMaxWidth(), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .navigationBarsPadding(), + 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)), + 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) } @@ -98,11 +102,19 @@ fun InfoBottomSheet( } @Composable -private fun InfoSheetContent(title: String, message: String, confirmText: String, dismissText: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { +private fun InfoSheetContent( + title: String, + message: String, + confirmText: String, + dismissText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), ) { Text( text = title, @@ -118,9 +130,10 @@ private fun InfoSheetContent(title: String, message: String, confirmText: String ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { 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 index 97c620d11..3b672b61a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -36,78 +36,79 @@ import kotlin.math.sin * 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) +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, + ) } - 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 @@ -190,7 +191,10 @@ fun rememberRoundedCornerHorizontalPadding(fallback: Dp = 0.dp): PaddingValues { } @RequiresApi(api = 31) -private fun RoundedCorner.calculateTopPaddingForComponent(componentX: Int, componentTop: Int): Int { +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 @@ -204,7 +208,10 @@ private fun RoundedCorner.calculateTopPaddingForComponent(componentX: Int, compo } @RequiresApi(api = 31) -private fun RoundedCorner.calculateBottomPaddingForComponent(componentX: Int, componentBottom: Int): Int { +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 @@ -218,7 +225,10 @@ private fun RoundedCorner.calculateBottomPaddingForComponent(componentX: Int, co } @RequiresApi(api = 31) -private fun RoundedCorner.calculateStartPaddingForComponent(componentLeft: Int, componentY: Int): Int { +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 @@ -232,7 +242,10 @@ private fun RoundedCorner.calculateStartPaddingForComponent(componentLeft: Int, } @RequiresApi(api = 31) -private fun RoundedCorner.calculateEndPaddingForComponent(componentRight: Int, componentY: Int): Int { +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 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 c0a2b7306..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/buildLinkAnnotation.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/buildLinkAnnotation.kt index 270a2aa72..9da615869 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,27 +9,35 @@ import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextDecoration @Composable -fun textLinkStyles(): TextLinkStyles = TextLinkStyles( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - pressedStyle = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - background = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.medium), - ), -) +fun textLinkStyles(): TextLinkStyles = + TextLinkStyles( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + 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( - url = url, - styles = textLinkStyles(), -) +fun buildLinkAnnotation(url: String): LinkAnnotation = + LinkAnnotation.Url( + url = url, + styles = textLinkStyles(), + ) @Composable -fun buildClickableAnnotation(text: String, onClick: LinkInteractionListener): LinkAnnotation = LinkAnnotation.Clickable( - tag = text, - styles = textLinkStyles(), - linkInteractionListener = onClick, -) +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 d7ad29351..a29b65e08 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 @@ -9,7 +9,11 @@ import kotlinx.serialization.modules.SerializersModule import okio.BufferedSink import okio.BufferedSource -class DataStoreKotlinxSerializer(override val defaultValue: T, private val serializer: KSerializer, private val customSerializersModule: SerializersModule? = null) : OkioSerializer { +class DataStoreKotlinxSerializer( + override val defaultValue: T, + private val serializer: KSerializer, + private val customSerializersModule: SerializersModule? = null, +) : OkioSerializer { private val json = Json { ignoreUnknownKeys = true @@ -18,11 +22,15 @@ class DataStoreKotlinxSerializer(override val defaultValue: T, private val se } } - override suspend fun readFrom(source: BufferedSource): T = runCatching { - json.decodeFromBufferedSource(serializer, source) - }.getOrDefault(defaultValue) + 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 8a5071358..dc8ed8a35 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 @@ -13,16 +13,22 @@ import kotlinx.serialization.KSerializer import okio.FileSystem import okio.Path.Companion.toPath -fun createDataStore(fileName: String, context: Context, defaultValue: T, serializer: KSerializer, scope: CoroutineScope, migrations: List> = emptyList()) = - DataStoreFactory.create( - storage = +fun createDataStore( + fileName: String, + context: Context, + defaultValue: T, + serializer: KSerializer, + scope: CoroutineScope, + migrations: List> = emptyList(), +) = DataStoreFactory.create( + storage = OkioStorage( fileSystem = FileSystem.SYSTEM, serializer = - DataStoreKotlinxSerializer( - defaultValue = defaultValue, - serializer = serializer, - ), + DataStoreKotlinxSerializer( + defaultValue = defaultValue, + serializer = serializer, + ), producePath = { context.filesDir .resolve(fileName) @@ -30,13 +36,14 @@ fun createDataStore(fileName: String, context: Context, defaultValue: T, ser .toPath() }, ), - scope = scope, - migrations = migrations, - ) + scope = scope, + migrations = migrations, +) -inline fun DataStore.safeData(defaultValue: T): Flow = data.catch { e -> - when (e) { - is IOException -> emit(defaultValue) - else -> throw e +inline fun DataStore.safeData(defaultValue: T): Flow = + data.catch { e -> + when (e) { + is IOException -> emit(defaultValue) + 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 87a318353..3343c700b 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 @@ -17,35 +17,37 @@ inline fun dankChatPreferencesMigration( context: Context, prefs: SharedPreferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE), crossinline migrateValue: suspend (currentData: T, key: K, value: Any?) -> T, -): DataMigration where K : Enum, K : PreferenceKeys = dankChatMigration( - context = context, - prefs = prefs, - keyMapper = { context.getString(it.id) }, - migrateValue = migrateValue, -) +): DataMigration where K : Enum, K : PreferenceKeys = + dankChatMigration( + context = context, + prefs = prefs, + keyMapper = { context.getString(it.id) }, + migrateValue = migrateValue, + ) inline fun dankChatMigration( context: Context, prefs: SharedPreferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE), crossinline keyMapper: (K) -> String, 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) -> - val mapped = map[key] ?: return@fold acc - migrateValue(acc, mapped, value) - } - }.getOrDefault(currentData) +): 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) -> + val mapped = map[key] ?: return@fold acc + migrateValue(acc, mapped, value) + } + }.getOrDefault(currentData) + } + + override suspend fun shouldMigrate(currentData: T): Boolean = map.keys.any(prefs::contains) + + override suspend fun cleanUp() = prefs.edit { map.keys.forEach(::remove) } } - 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 @@ -58,7 +60,11 @@ 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 = 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 @@ -66,6 +72,11 @@ 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 = 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/BottomSheetExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt index 4c838374b..63d8aff77 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt @@ -33,9 +33,15 @@ suspend fun BottomSheetBehavior.awaitState(targetState: Int) { return suspendCancellableCoroutine { val callback = object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit - - override fun onStateChanged(bottomSheet: View, newState: Int) { + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) = Unit + + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { if (newState == targetState) { removeBottomSheetCallback(this) it.resume(Unit) 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 index 43c087784..6f42069a4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt @@ -2,47 +2,62 @@ 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( + 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 +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)) + 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) { +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 659657dd2..3c6263857 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,9 +24,15 @@ inline fun Collection

.partitionIsInstance(): Pair, return Pair(first, second) } -inline fun Collection.replaceIf(replacement: T, predicate: (T) -> Boolean): List = 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> { +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 f098c0cbb..13c6782d4 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 @@ -11,7 +11,9 @@ import androidx.core.graphics.ColorUtils * preserving hue and saturation as much as possible. */ @ColorInt -fun Int.normalizeColor(@ColorInt background: Int): Int { +fun Int.normalizeColor( + @ColorInt background: Int, +): Int { // calculateContrast requires opaque colors; force full alpha on both val opaqueColor = this or 0xFF000000.toInt() val opaqueBackground = background or 0xFF000000.toInt() 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 34e1fdd15..edbecffa0 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 @@ -11,28 +11,33 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield import kotlin.time.Duration -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 = launch { - val scope = TimerScope() - - while (true) { - try { - action(scope) - } catch (ex: Exception) { - Log.e("TimerScope", Log.getStackTraceString(ex)) - } +suspend fun Collection.concurrentMap(block: suspend (T) -> R): List = + coroutineScope { + map { async { block(it) } }.awaitAll() + } - 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) { + Log.e("TimerScope", Log.getStackTraceString(ex)) + } + + if (scope.isCancelled) { + break + } + + delay(interval) + yield() } - - delay(interval) - yield() } -} class TimerScope { var isCancelled: Boolean = false 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 c4c0ad995..0301bd599 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 @@ -10,12 +10,13 @@ import com.flxrs.dankchat.data.twitch.emote.GenericEmote import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem 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?.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() @@ -37,16 +38,21 @@ fun List?.toEmoteItemsWithFront(channel: UserName?): List.moveToFront(channel: UserName?): List = this - .partition { it.emoteType.title.equals(channel?.value, ignoreCase = true) } - .run { first + second } +fun List.moveToFront(channel: UserName?): List = + this + .partition { it.emoteType.title.equals(channel?.value, ignoreCase = true) } + .run { first + second } inline fun measureTimeValue(block: () -> V): Pair { val start = System.currentTimeMillis() return block() to System.currentTimeMillis() - start } -inline fun measureTimeAndLog(tag: String, toLoad: String, block: () -> V): V { +inline fun measureTimeAndLog( + tag: String, + toLoad: String, + block: () -> V, +): V { val (result, time) = measureTimeValue(block) when { result != null -> Log.i(tag, "Loaded $toLoad in $time ms") @@ -56,9 +62,10 @@ inline fun measureTimeAndLog(tag: String, toLoad: String, block: () -> V): V return result } -inline fun Json.decodeOrNull(json: String): T? = runCatching { - decodeFromString(json) -}.getOrNull() +inline fun Json.decodeOrNull(json: String): T? = + runCatching { + decodeFromString(json) + }.getOrNull() val Int.isEven get() = (this % 2 == 0) 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 cd0ee3f7f..abbeec82e 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 @@ -8,17 +8,26 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.transformLatest -fun mutableSharedFlowOf(defaultValue: T, replayValue: Int = 1, extraBufferCapacity: Int = 0, onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST): MutableSharedFlow = +fun mutableSharedFlowOf( + defaultValue: T, + replayValue: Int = 1, + extraBufferCapacity: Int = 0, + 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() @@ -26,20 +35,27 @@ inline val SharedFlow.firstValue: T inline val SharedFlow.firstValueOrNull: T? get() = replayCache.firstOrNull() -fun MutableSharedFlow>.increment(key: UserName, amount: Int) = tryEmit( +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>.assign(key: UserName, value: T) = tryEmit( +fun MutableSharedFlow>.clear(key: UserName) = + tryEmit( + firstValue.apply { + put(key, 0) + }, + ) + +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/ModerationOperations.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt index 27f165642..317eea2f3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -17,73 +17,83 @@ fun MutableList.replaceOrAddHistoryModerationMessage(moderationMessage } } -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 - } +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 = 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) + 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 } - } - 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) } - this[idx] = item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) + else -> { + continue + } } + } - else -> { - continue - } + if (addSystemMessage) { + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) } } - 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 == 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 + } + } + } -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) { + for (idx in indices) { 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 + 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 } } - } - 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) } - addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) -} - private fun MutableList.checkForStackedTimeouts(moderationMessage: ModerationMessage): Boolean { if (moderationMessage.canStack) { val end = (lastIndex - 20).coerceAtLeast(0) 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 1e0403129..90a11dc9b 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 @@ -39,7 +39,11 @@ fun String.removeDuplicateWhitespace(): Pair> { return stringBuilder.toString() to removedSpacesPositions } -data class CodePointAnalysis(val supplementaryCodePointPositions: List, val deduplicatedString: String, val removedSpacesPositions: List) +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 { @@ -198,7 +202,10 @@ 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 @@ -208,7 +215,8 @@ inline fun CharSequence.indexOfFirst(startIndex: Int = 0, predicate: (Char) -> B return -1 } -fun String.truncate(maxLength: Int = 120) = when { - length <= maxLength -> this - else -> take(maxLength) + Typography.ellipsis -} +fun String.truncate(maxLength: Int = 120) = + when { + length <= maxLength -> this + 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 index d726e4588..8be1fc287 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt @@ -5,12 +5,22 @@ 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) -} +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 { +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) { 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 index b6ab3b445..324403dc2 100644 --- 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 @@ -17,12 +17,18 @@ class MockIrcServer : AutoCloseable { private val listener = object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { serverSocket = webSocket connectedLatch.countDown() } - override fun onMessage(webSocket: WebSocket, text: String) { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { text.trimEnd('\r', '\n').split("\r\n").forEach { line -> sentFrames.add(line) handleIrcCommand(webSocket, line) @@ -37,7 +43,10 @@ class MockIrcServer : AutoCloseable { server.start() } - fun awaitConnection(timeout: Long = 5, unit: TimeUnit = TimeUnit.SECONDS): Boolean = connectedLatch.await(timeout, unit) + fun awaitConnection( + timeout: Long = 5, + unit: TimeUnit = TimeUnit.SECONDS, + ): Boolean = connectedLatch.await(timeout, unit) fun sendToClient(ircLine: String) { serverSocket?.send("$ircLine\r\n") @@ -48,7 +57,10 @@ class MockIrcServer : AutoCloseable { server.close() } - private fun handleIrcCommand(webSocket: WebSocket, line: String) { + private fun handleIrcCommand( + webSocket: WebSocket, + line: String, + ) { when { line.startsWith("NICK ") -> { val nick = line.removePrefix("NICK ") @@ -71,7 +83,10 @@ class MockIrcServer : AutoCloseable { } } - private fun sendMotd(webSocket: WebSocket, nick: String) { + 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") 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 index ba2cdf6ed..b23f123de 100644 --- 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 @@ -23,15 +23,15 @@ 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 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 @@ -49,169 +49,186 @@ internal class ChatConnectionTest { 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 - } + 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]) + 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]) + 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) + 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") + 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") + 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") + 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) + 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") + 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) + 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") + 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) { + 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 diff --git a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt index 71b7c4adc..cba7d8e57 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt @@ -89,184 +89,196 @@ internal class ChannelDataCoordinatorTest { } @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 + 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() + coordinator.loadGlobalData() - assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) - } + 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 + 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) + val failure = DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout")) + dataLoadingFailures.value = setOf(failure) - coordinator.loadGlobalData() + coordinator.loadGlobalData() - val state = coordinator.globalLoadingState.value - assertIs(state) - assertEquals(1, state.failures.size) - assertEquals(DataLoadingStep.GlobalBTTVEmotes, state.failures.first().step) - } + 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() } - } + 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 + fun `loadChannelData transitions to Loaded`() = + runTest(testDispatcher) { + val channel = UserName("testchannel") + coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded - coordinator.loadChannelData(channel) + coordinator.loadChannelData(channel) - assertEquals(ChannelLoadingState.Loaded, coordinator.getChannelLoadingState(channel).value) - } + 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) + 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) + coordinator.loadChannelData(channel) - val state = coordinator.getChannelLoadingState(channel).value - assertIs(state) - assertEquals(1, state.failures.size) - } + 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 + 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.loadGlobalData() + assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) - coordinator.globalLoadingState.test { - assertEquals(GlobalLoadingState.Loaded, awaitItem()) + coordinator.globalLoadingState.test { + assertEquals(GlobalLoadingState.Loaded, awaitItem()) - val chatFailure = ChatLoadingFailure(ChatLoadingStep.RecentMessages(UserName("test")), RuntimeException("fail")) - chatLoadingFailures.value = setOf(chatFailure) + val chatFailure = ChatLoadingFailure(ChatLoadingStep.RecentMessages(UserName("test")), RuntimeException("fail")) + chatLoadingFailures.value = setOf(chatFailure) - val failed = awaitItem() - assertIs(failed) - assertEquals(1, failed.chatFailures.size) + 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"))), - ) + 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) - coordinator.retryDataLoading(failedState) + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), + ) - coVerify { globalDataLoader.loadGlobalBTTVEmotes() } - } + 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"))), - ) + 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 - coordinator.retryDataLoading(failedState) + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.ChannelBTTVEmotes(channel, DisplayName("testchannel"), UserId("123")), RuntimeException("fail"))), + ) - coVerify { channelDataLoader.loadChannelData(channel) } - } + 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"))), - ) + 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 - coordinator.retryDataLoading(failedState) + val failedState = + GlobalLoadingState.Failed( + chatFailures = setOf(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), RuntimeException("fail"))), + ) - coVerify { channelDataLoader.loadChannelData(channel) } - } + 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"))), - ) + 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) - coordinator.retryDataLoading(failedState) + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), + ) - assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) - } + 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) + 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)) + val failedState = GlobalLoadingState.Failed(failures = setOf(failure)) - coordinator.retryDataLoading(failedState) + coordinator.retryDataLoading(failedState) - assertIs(coordinator.globalLoadingState.value) - } + assertIs(coordinator.globalLoadingState.value) + } @Test - fun `cleanupChannel removes channel state`() = runTest(testDispatcher) { - val channel = UserName("testchannel") - coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded + 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.loadChannelData(channel) + assertEquals(ChannelLoadingState.Loaded, coordinator.getChannelLoadingState(channel).value) - coordinator.cleanupChannel(channel) + coordinator.cleanupChannel(channel) - assertEquals(ChannelLoadingState.Idle, coordinator.getChannelLoadingState(channel).value) - } + 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 index 6747d1508..343dbfc1e 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt @@ -81,128 +81,138 @@ internal class ChannelDataLoaderTest { } @Test - fun `loadChannelData returns Loaded when all steps succeed`() = runTest(testDispatcher) { - coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo - stubAllEmotesAndBadgesSuccess() + fun `loadChannelData returns Loaded when all steps succeed`() = + runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo + stubAllEmotesAndBadgesSuccess() - val result = loader.loadChannelData(testChannel) + val result = loader.loadChannelData(testChannel) - assertEquals(ChannelLoadingState.Loaded, result) - } + 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() + 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) + val result = loader.loadChannelData(testChannel) - assertIs(result) - assertTrue(result.failures.isEmpty()) - } + 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() + 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) + val result = loader.loadChannelData(testChannel) - assertEquals(ChannelLoadingState.Loaded, result) - coVerify { getChannelsUseCase(listOf(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()) - } + 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 }) - } + 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 }) } - } + 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") + fun `loadChannelData returns Failed on unexpected exception`() = + runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } throws RuntimeException("unexpected") - val result = loader.loadChannelData(testChannel) + val result = loader.loadChannelData(testChannel) - assertIs(result) - assertTrue(result.failures.isEmpty()) - } + 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) + 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) + fun `loadChannelBadges returns null on success`() = + runTest(testDispatcher) { + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.success(Unit) - val result = loader.loadChannelBadges(testChannel, testChannelId) + val result = loader.loadChannelBadges(testChannel, testChannelId) - assertEquals(null, result) - } + assertEquals(null, result) + } @Test - fun `loadChannelBadges returns failure on error`() = runTest(testDispatcher) { - coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.failure(RuntimeException("fail")) + fun `loadChannelBadges returns failure on error`() = + runTest(testDispatcher) { + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.failure(RuntimeException("fail")) - val result = loader.loadChannelBadges(testChannel, testChannelId) + val result = loader.loadChannelBadges(testChannel, testChannelId) - assertIs(result) - assertEquals(testChannel, result.channel) - } + 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 index 612717f15..c147cbcc4 100644 --- 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 @@ -18,7 +18,10 @@ internal class SuggestionFilteringTest { emojiRepository = mockk(), ) - private fun emote(code: String, id: String = code) = GenericEmote(code = code, url = "", lowResUrl = "", id = id, scale = 1, emoteType = EmoteType.GlobalTwitchEmote) + private fun emote( + code: String, + id: String = code, + ) = GenericEmote(code = code, url = "", lowResUrl = "", id = id, scale = 1, emoteType = EmoteType.GlobalTwitchEmote) // region filterEmotes 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 index d12e7c612..c40085207 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt @@ -58,379 +58,404 @@ internal class FeatureTourViewModelTest { // -- Post-onboarding step resolution -- @Test - fun `initial state is Idle when onboarding not completed`() = runTest(testDispatcher) { - viewModel.uiState.test { - assertEquals(PostOnboardingStep.Idle, awaitItem().postOnboardingStep) + 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) + 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) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = true, ready = true) - // State stays Idle — StateFlow deduplicates, no new emission - expectNoEvents() + // 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) + 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) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = false) - // State stays Idle — StateFlow deduplicates, no new emission - expectNoEvents() + // 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) + 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) + 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, - ) + 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) } - 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, - ) + 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) } - 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) + 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) + 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) + 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.OverflowMenu, expectMostRecentItem().currentTourStep) - viewModel.advance() - assertEquals(TourStep.ConfigureActions, expectMostRecentItem().currentTourStep) + viewModel.advance() + assertEquals(TourStep.ConfigureActions, expectMostRecentItem().currentTourStep) - viewModel.advance() - assertEquals(TourStep.SwipeGesture, expectMostRecentItem().currentTourStep) + viewModel.advance() + assertEquals(TourStep.SwipeGesture, expectMostRecentItem().currentTourStep) - viewModel.advance() - assertEquals(TourStep.RecoveryFab, 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) + 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) + 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() } + fun `startTour after completion is no-op`() = + runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + repeat(TourStep.entries.size) { viewModel.advance() } - viewModel.startTour() + viewModel.startTour() - assertFalse(expectMostRecentItem().isTourActive) + 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() - } + 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) - } + 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() - } + fun `advance persists current step index`() = + runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // step 1 + cancelAndIgnoreRemainingEvents() + } - assertEquals(1, settingsFlow.value.featureTourStep) - } + 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 + 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() + viewModel.skipTour() - assertEquals(PostOnboardingStep.Complete, expectMostRecentItem().postOnboardingStep) - } + assertEquals(PostOnboardingStep.Complete, expectMostRecentItem().postOnboardingStep) + } - val persisted = settingsFlow.value - assertEquals(CURRENT_TOUR_VERSION, persisted.featureTourVersion) - assertTrue(persisted.hasShownToolbarHint) - } + 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) + 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 + viewModel.onToolbarHintDismissed() + viewModel.onToolbarHintDismissed() // second call should be no-op - assertEquals(PostOnboardingStep.FeatureTour, expectMostRecentItem().postOnboardingStep) + 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() - } + 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) - } + 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) + 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) + 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) + 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) + 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, - ) + 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) } - 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, - ) + 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) } - 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 - ) + 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) } - viewModel.onChannelsChanged(empty = false, ready = true) - - viewModel.startTour() - - val state = expectMostRecentItem() - assertEquals(TourStep.RecoveryFab, state.currentTourStep) - assertTrue(state.gestureInputHidden) } - } // -- Helpers -- From d63829c6d5826606a4b2466f13149e4794ff813b Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 11:23:59 +0200 Subject: [PATCH 171/349] refactor(sheets): Migrate ConfirmationBottomSheet and UploadDisclaimerSheet to M3, remove StyledBottomSheet --- .../ui/main/dialog/MainScreenDialogs.kt | 167 ++++++++------ .../utils/compose/ConfirmationBottomSheet.kt | 83 ++++--- .../utils/compose/InputBottomSheet.kt | 216 +++++++++++++----- .../utils/compose/StyledBottomSheet.kt | 122 ---------- 4 files changed, 309 insertions(+), 279 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt 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 index 988c62874..8c4f8d3dc 100644 --- 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 @@ -1,6 +1,7 @@ package com.flxrs.dankchat.ui.main.dialog import android.content.ClipData +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -49,7 +51,6 @@ 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 com.flxrs.dankchat.utils.compose.StyledBottomSheet import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -103,10 +104,11 @@ fun MainScreenDialogs( } if (dialogState.showModActions && modActionsChannel != null) { - val modActionsViewModel: ModActionsViewModel = koinViewModel( - key = "mod-actions-${modActionsChannel.value}", - parameters = { parametersOf(modActionsChannel) }, - ) + val modActionsViewModel: ModActionsViewModel = + koinViewModel( + key = "mod-actions-${modActionsChannel.value}", + parameters = { parametersOf(modActionsChannel) }, + ) val shieldModeActive by modActionsViewModel.shieldModeActive.collectAsStateWithLifecycle() ModActionsDialog( roomState = modActionsViewModel.roomState, @@ -211,10 +213,11 @@ fun MainScreenDialogs( } dialogState.messageOptionsParams?.let { params -> - val viewModel: MessageOptionsViewModel = koinViewModel( - key = params.messageId, - parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) }, - ) + val viewModel: MessageOptionsViewModel = + koinViewModel( + key = params.messageId, + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) }, + ) val state by viewModel.state.collectAsStateWithLifecycle() (state as? MessageOptionsState.Found)?.let { s -> MessageOptionsDialog( @@ -266,23 +269,26 @@ fun MainScreenDialogs( } dialogState.emoteInfoEmotes?.let { emotes -> - val viewModel: EmoteInfoViewModel = koinViewModel( - key = emotes.joinToString { it.id }, - parameters = { parametersOf(emotes) }, - ) + val viewModel: EmoteInfoViewModel = + koinViewModel( + key = emotes.joinToString { it.id }, + parameters = { parametersOf(emotes) }, + ) val sheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() val whisperTarget by chatInputViewModel.whisperTarget.collectAsStateWithLifecycle() - val canUseEmote = isLoggedIn && when (sheetState) { - is FullScreenSheetState.Closed, - is FullScreenSheetState.Replies, - -> true + val canUseEmote = + isLoggedIn && + when (sheetState) { + is FullScreenSheetState.Closed, + is FullScreenSheetState.Replies, + -> true - is FullScreenSheetState.Mention, - is FullScreenSheetState.Whisper, - -> whisperTarget != null + is FullScreenSheetState.Mention, + is FullScreenSheetState.Whisper, + -> whisperTarget != null - is FullScreenSheetState.History -> false - } + is FullScreenSheetState.History -> false + } EmoteInfoDialog( items = viewModel.items, isLoggedIn = canUseEmote, @@ -298,10 +304,11 @@ fun MainScreenDialogs( } dialogState.userPopupParams?.let { params -> - val viewModel: UserPopupViewModel = koinViewModel( - key = "${params.targetUserId}${params.channel?.value.orEmpty()}", - parameters = { parametersOf(params) }, - ) + val viewModel: UserPopupViewModel = + koinViewModel( + key = "${params.targetUserId}${params.channel?.value.orEmpty()}", + parameters = { parametersOf(params) }, + ) val state by viewModel.userPopupState.collectAsStateWithLifecycle() UserPopupDialog( state = state, @@ -344,57 +351,77 @@ fun MainScreenDialogs( } @Composable -private fun UploadDisclaimerSheet(host: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { +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, - ) + 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, + ) + } } - } - StyledBottomSheet(onDismiss = onDismiss) { - 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), - ) + 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, - ) + 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)) + 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)) + } } } } 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 index 70ec53573..3e121380a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt @@ -1,14 +1,18 @@ 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.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 @@ -16,6 +20,7 @@ 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, @@ -25,43 +30,57 @@ fun ConfirmationBottomSheet( confirmText: String = stringResource(R.string.dialog_ok), dismissText: String = stringResource(R.string.dialog_cancel), ) { - StyledBottomSheet(onDismiss = onDismiss) { - 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) { + 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 = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), + 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 = onDismiss, - modifier = Modifier.weight(1f), - ) { - Text(dismissText) + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) } - Spacer(modifier = Modifier.width(12.dp)) - Button( - onClick = onConfirm, - modifier = Modifier.weight(1f), + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), ) { - Text(confirmText) + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Text(dismissText) + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + ) { + 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 index 1ad6efe94..bcfb5362a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt @@ -1,12 +1,23 @@ 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 @@ -20,20 +31,32 @@ 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( @@ -57,67 +80,150 @@ fun InputBottomSheet( focusRequester.requestFocus() } - StyledBottomSheet(onDismiss = onDismiss, addBottomSpacing = false, dismissOnKeyboardClose = true) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp), + val sheetState = + rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), ) - 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), - ) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } - 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), - ) + 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 + } } - Button( - onClick = { onConfirm(trimmed) }, - enabled = isValid, - modifier = Modifier - .align(Alignment.End) - .padding(top = 8.dp), + 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), ) { - Text(confirmText) + 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/StyledBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt deleted file mode 100644 index 73346a9cc..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StyledBottomSheet.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.flxrs.dankchat.utils.compose - -import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -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.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -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.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -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 java.util.concurrent.CancellationException - -@Composable -fun StyledBottomSheet(onDismiss: () -> Unit, addBottomSpacing: Boolean = true, dismissOnKeyboardClose: Boolean = false, content: @Composable ColumnScope.() -> Unit) { - 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) - .padding(bottom = if (addBottomSpacing) 32.dp else 0.dp) - .navigationBarsPadding() - .imePadding(), - ) { - if (dismissOnKeyboardClose) { - 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), - ) - - content() - } - } - } -} From 09d33f6e87be9e6ff4f872455b319c49e284d14f Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 11:32:31 +0200 Subject: [PATCH 172/349] fix(strings): Rename "Mod actions" to "Channel moderation" with translations --- app/src/main/res/values-b+zh+Hant+TW/strings.xml | 4 ++-- app/src/main/res/values-be-rBY/strings.xml | 4 ++-- app/src/main/res/values-ca/strings.xml | 4 ++-- app/src/main/res/values-cs/strings.xml | 4 ++-- app/src/main/res/values-de-rDE/strings.xml | 4 ++-- app/src/main/res/values-en-rAU/strings.xml | 4 ++-- app/src/main/res/values-en-rGB/strings.xml | 4 ++-- app/src/main/res/values-en/strings.xml | 4 ++-- app/src/main/res/values-es-rES/strings.xml | 4 ++-- app/src/main/res/values-fi-rFI/strings.xml | 4 ++-- app/src/main/res/values-fr-rFR/strings.xml | 4 ++-- app/src/main/res/values-hu-rHU/strings.xml | 4 ++-- app/src/main/res/values-it/strings.xml | 4 ++-- app/src/main/res/values-ja-rJP/strings.xml | 4 ++-- app/src/main/res/values-kk-rKZ/strings.xml | 4 ++-- app/src/main/res/values-or-rIN/strings.xml | 4 ++-- app/src/main/res/values-pl-rPL/strings.xml | 4 ++-- app/src/main/res/values-pt-rBR/strings.xml | 4 ++-- app/src/main/res/values-pt-rPT/strings.xml | 4 ++-- app/src/main/res/values-ru-rRU/strings.xml | 4 ++-- app/src/main/res/values-sr/strings.xml | 4 ++-- app/src/main/res/values-tr-rTR/strings.xml | 4 ++-- app/src/main/res/values-uk-rUA/strings.xml | 4 ++-- app/src/main/res/values/strings.xml | 4 ++-- 24 files changed, 48 insertions(+), 48 deletions(-) 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 eafa54320..c2bf38289 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 @@ -261,14 +261,14 @@ 全螢幕 退出全螢幕 隱藏輸入框 - 頻道設定 + 頻道管理 最多 %1$d 個動作 搜尋訊息 上一則訊息 切換實況 - 頻道設定 + 頻道管理 全螢幕 隱藏輸入框 設定動作 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index f6f24b7ad..8215c26a4 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -626,13 +626,13 @@ На ўвесь экран Выйсці з поўнаэкраннага рэжыму Схаваць увод - Налады канала + Мадэрацыя канала Пошук паведамленняў Апошняе паведамленне Пераключыць трансляцыю - Налады канала + Мадэрацыя канала На ўвесь экран Схаваць увод Наладзіць дзеянні diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 5c6e0499f..bc78a49c1 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -560,13 +560,13 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Pantalla completa Surt de la pantalla completa Amaga l\'entrada - Configuració del canal + Moderació del canal Cerca missatges Últim missatge Commuta l\'emissió - Configuració del canal + Moderació del canal Pantalla completa Amaga l\'entrada Configura accions diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index fd1b57361..5e5ccd5a2 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -627,13 +627,13 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Celá obrazovka Ukončit celou obrazovku Skrýt vstup - Nastavení kanálu + Moderování kanálu Hledat zprávy Poslední zpráva Přepnout stream - Nastavení kanálu + Moderování kanálu Celá obrazovka Skrýt vstup Konfigurovat akce diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 0aab3bc8a..79b6083ad 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -627,13 +627,13 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Vollbild Vollbild beenden Eingabe ausblenden - Kanaleinstellungen + Kanalmoderation Nachrichten durchsuchen Letzte Nachricht Stream umschalten - Kanaleinstellungen + Kanalmoderation Vollbild Eingabe ausblenden Aktionen konfigurieren diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index d60a645d3..cea44c3ed 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -435,11 +435,11 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Exit fullscreen Hide input - Channel settings + Channel moderation Search messages Last message Toggle stream - Channel settings + Channel moderation Fullscreen Hide input Configure actions diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index a3f26ff89..a37728d5d 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -436,11 +436,11 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Exit fullscreen Hide input - Channel settings + Channel moderation Search messages Last message Toggle stream - Channel settings + Channel moderation Fullscreen Hide input Configure actions diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7c54dc08f..4be65d8cf 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -621,13 +621,13 @@ Fullscreen Exit fullscreen Hide input - Channel settings + Channel moderation Search messages Last message Toggle stream - Channel settings + Channel moderation Fullscreen Hide input Configure actions diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 4d24320e9..e5cc6cc00 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -636,13 +636,13 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Pantalla completa Salir de pantalla completa Ocultar entrada - Ajustes del canal + Moderación del canal Buscar mensajes Último mensaje Alternar stream - Ajustes del canal + Moderación del canal Pantalla completa Ocultar entrada Configurar acciones diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 2602efe78..1524d14ab 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -618,13 +618,13 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Koko näyttö Poistu koko näytöstä Piilota syöttö - Kanavan asetukset + Kanavan moderointi Hae viestejä Viimeisin viesti Vaihda lähetys - Kanavan asetukset + Kanavan moderointi Koko näyttö Piilota syöttö Muokkaa toimintoja diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 82629056e..050139160 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -620,13 +620,13 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Plein écran Quitter le plein écran Masquer la saisie - Paramètres de la chaîne + Modération du canal Rechercher des messages Dernier message Basculer le stream - Paramètres de la chaîne + Modération du canal Plein écran Masquer la saisie Configurer les actions diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index af407fc19..a53eb9cc9 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -605,13 +605,13 @@ Teljes képernyő Kilépés a teljes képernyőből Bevitel elrejtése - Csatornabeállítások + Csatorna moderálás Üzenetek keresése Utolsó üzenet Közvetítés váltása - Csatornabeállítások + Csatorna moderálás Teljes képernyő Bevitel elrejtése Műveletek beállítása diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3850c8415..5ca859e34 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -603,13 +603,13 @@ Schermo intero Esci dallo schermo intero Nascondi input - Impostazioni canale + Moderazione canale Cerca messaggi Ultimo messaggio Attiva/disattiva stream - Impostazioni canale + Moderazione canale Schermo intero Nascondi input Configura azioni diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 70804fcab..cdd1000d5 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -586,13 +586,13 @@ 全画面 全画面を終了 入力欄を非表示 - チャンネル設定 + チャンネルモデレーション メッセージを検索 最後のメッセージ 配信を切り替え - チャンネル設定 + チャンネルモデレーション 全画面 入力欄を非表示 アクションを設定 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 404642bdc..c11049399 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -259,7 +259,7 @@ Толық экран Толық экраннан шығу Енгізуді жасыру - Арна параметрлері + Арна модерациясы Ең көбі %1$d әрекет Ең көбі %1$d әрекет @@ -267,7 +267,7 @@ Хабарларды іздеу Соңғы хабарлама Ағынды қосу/өшіру - Арна параметрлері + Арна модерациясы Толық экран Енгізуді жасыру Әрекеттерді баптау diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index da677dd31..2e5a59753 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -259,7 +259,7 @@ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ବାହାରନ୍ତୁ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ - ଚ୍ୟାନେଲ ସେଟିଂସ୍ + ଚ୍ୟାନେଲ ମଡରେସନ ସର୍ବାଧିକ %1$d କ୍ରିୟା ସର୍ବାଧିକ %1$d କ୍ରିୟା @@ -267,7 +267,7 @@ ବାର୍ତ୍ତା ଖୋଜନ୍ତୁ ଶେଷ ବାର୍ତ୍ତା ଷ୍ଟ୍ରିମ୍ ଟୋଗଲ୍ କରନ୍ତୁ - ଚ୍ୟାନେଲ ସେଟିଂସ୍ + ଚ୍ୟାନେଲ ମଡରେସନ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ କ୍ରିୟାଗୁଡିକ ବିନ୍ୟାସ କରନ୍ତୁ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 39f3e8aa3..124eeb83c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -645,13 +645,13 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pełny ekran Wyjdź z pełnego ekranu Ukryj pole wpisywania - Ustawienia kanału + Moderacja kanału Szukaj wiadomości Ostatnia wiadomość Przełącz transmisję - Ustawienia kanału + Moderacja kanału Pełny ekran Ukryj pole wpisywania Konfiguruj akcje diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d2eba5263..3d79b4222 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -615,13 +615,13 @@ Tela cheia Sair da tela cheia Ocultar entrada - Configurações do canal + Moderação do canal Pesquisar mensagens Última mensagem Alternar stream - Configurações do canal + Moderação do canal Tela cheia Ocultar entrada Configurar ações diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index c8d40288e..d1d40961a 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -605,13 +605,13 @@ Ecrã inteiro Sair do ecrã inteiro Ocultar entrada - Definições do canal + Moderação do canal Pesquisar mensagens Última mensagem Alternar transmissão - Definições do canal + Moderação do canal Ecrã inteiro Ocultar entrada Configurar ações diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 6ffcc748c..a6b24abac 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -631,13 +631,13 @@ На весь экран Выйти из полноэкранного режима Скрыть ввод - Настройки канала + Модерация канала Поиск сообщений Последнее сообщение Переключить трансляцию - Настройки канала + Модерация канала На весь экран Скрыть ввод Настроить действия diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index a10996337..fee3cb965 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -625,13 +625,13 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Цео екран Изађи из целог екрана Сакриј унос - Подешавања канала + Модерација канала Претражи поруке Последња порука Укључи/искључи стрим - Подешавања канала + Модерација канала Цео екран Сакриј унос Подеси акције diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 22c5bd106..4ff4f7c4a 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -626,13 +626,13 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Tam ekran Tam ekrandan çık Girişi gizle - Kanal ayarları + Kanal moderasyonu Mesajları ara Son mesaj Yayını aç/kapat - Kanal ayarları + Kanal moderasyonu Tam ekran Girişi gizle Eylemleri yapılandır diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 785a8a6b1..30693e569 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -628,13 +628,13 @@ На весь екран Вийти з повноекранного режиму Сховати введення - Налаштування каналу + Модерація каналу Пошук повідомлень Останнє повідомлення Перемкнути трансляцію - Налаштування каналу + Модерація каналу На весь екран Сховати введення Налаштувати дії diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9db28b0f6..28667032b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -289,7 +289,7 @@ Fullscreen Exit fullscreen Hide input - Mod actions + Channel moderation Maximum of %1$d action Maximum of %1$d actions @@ -297,7 +297,7 @@ Search messages Last message Toggle stream - Mod actions + Channel moderation Fullscreen Hide input Debug From 26deea2370c266a120ec8b143c470baf00633bd5 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 11:40:23 +0200 Subject: [PATCH 173/349] fix(input): Add two-line helper text to hidden input, fix marquee stopping after 3 iterations --- .../preferences/components/PreferenceItem.kt | 2 +- .../dankchat/ui/main/input/ChatBottomBar.kt | 61 +++++++++++++++---- .../dankchat/ui/main/input/ChatInputLayout.kt | 6 +- 3 files changed, 54 insertions(+), 15 deletions(-) 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 53113f3b8..e32ece165 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 @@ -272,7 +272,7 @@ private fun RowScope.PreferenceItemContent( Text( text = title, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.basicMarquee(), + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), maxLines = 1, color = color, ) 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 index fd2ce9da1..a789e4a63 100644 --- 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 @@ -3,9 +3,11 @@ 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.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding @@ -18,10 +20,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.text.style.TextAlign +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 @@ -93,8 +97,9 @@ fun ChatBottomBar( val helperTextState = uiState.helperText val resolvedRoomState = helperTextState.roomStateParts.map { it.resolve() } val roomStateText = resolvedRoomState.joinToString(separator = ", ") - val helperText = listOfNotNull(roomStateText.ifEmpty { null }, helperTextState.streamInfo).joinToString(separator = " - ") - if (helperText.isNotEmpty()) { + val streamInfoText = helperTextState.streamInfo + val combinedText = listOfNotNull(roomStateText.ifEmpty { null }, streamInfoText).joinToString(separator = " - ") + if (combinedText.isNotEmpty()) { val horizontalPadding = when { isFullscreen && isInSplitLayout -> { @@ -111,6 +116,9 @@ fun ChatBottomBar( PaddingValues(horizontal = 16.dp) } } + val textMeasurer = rememberTextMeasurer() + val style = MaterialTheme.typography.labelSmall + val density = LocalDensity.current Surface( color = MaterialTheme.colorScheme.surfaceContainer, modifier = @@ -118,20 +126,51 @@ fun ChatBottomBar( .fillMaxWidth() .onGloballyPositioned { onHelperTextHeightChange(it.size.height) }, ) { - Text( - text = helperText, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + BoxWithConstraints( modifier = Modifier .navigationBarsPadding() .fillMaxWidth() .padding(horizontalPadding) .padding(vertical = 6.dp) - .basicMarquee(), - textAlign = TextAlign.Start, - ) + .animateContentSize(), + ) { + val maxWidthPx = with(density) { maxWidth.roundToPx() } + val fitsOnOneLine = + remember(combinedText, style, maxWidthPx) { + textMeasurer.measure(combinedText, style).size.width <= maxWidthPx + } + when { + fitsOnOneLine || streamInfoText == null || roomStateText.isEmpty() -> { + Text( + text = combinedText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), + ) + } + + else -> { + 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), + ) + } + } + } + } } } } 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 index 43daab7c3..a13624839 100644 --- 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 @@ -327,7 +327,7 @@ fun ChatInputLayout( style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - modifier = Modifier.fillMaxWidth().basicMarquee(), + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), ) } @@ -338,14 +338,14 @@ fun ChatInputLayout( style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - modifier = Modifier.fillMaxWidth().basicMarquee(), + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), ) Text( text = streamInfoText, style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - modifier = Modifier.fillMaxWidth().basicMarquee(), + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), ) } } From cc0345dc227ca4cea48f7d00849bc1b64ab27286 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 14:24:12 +0200 Subject: [PATCH 174/349] feat(chat): Add FAB actions menu, semi-transparent recovery FAB and helper text, extract predictive back modifier --- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 2 + .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 293 +++++++++++++++++- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 56 ++++ .../ui/main/MainScreenPagerContent.kt | 3 + .../dankchat/ui/main/input/ChatBottomBar.kt | 2 +- .../dankchat/ui/main/input/ChatInputLayout.kt | 9 +- .../utils/compose/PredictiveBackModifier.kt | 12 + 7 files changed, 358 insertions(+), 19 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/PredictiveBackModifier.kt 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 index e79e3ab6e..6e01d62fb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -40,6 +40,7 @@ fun ChatComposable( isFullscreen: Boolean = false, showFabs: Boolean = true, onRecover: () -> Unit = {}, + fabMenuCallbacks: FabMenuCallbacks? = null, contentPadding: PaddingValues = PaddingValues(), onScrollToBottom: () -> Unit = {}, onScrollDirectionChange: (Boolean) -> Unit = {}, @@ -83,6 +84,7 @@ fun ChatComposable( isFullscreen = isFullscreen, showFabs = showFabs, onRecover = onRecover, + fabMenuCallbacks = fabMenuCallbacks, contentPadding = contentPadding, scrollModifier = scrollModifier, onScrollToBottom = onScrollToBottom, 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 index 4ce2a5de5..4965a4c49 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -1,5 +1,7 @@ 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 @@ -8,18 +10,40 @@ 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.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.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.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.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -27,14 +51,18 @@ 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -44,12 +72,20 @@ 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.platform.LocalConfiguration 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.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 @@ -60,6 +96,7 @@ 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 data class ChatScreenCallbacks( val onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, @@ -85,6 +122,7 @@ fun ChatScreen( showInput: Boolean = true, isFullscreen: Boolean = false, onRecover: () -> Unit = {}, + fabMenuCallbacks: FabMenuCallbacks? = null, contentPadding: PaddingValues = PaddingValues(), onScrollToBottom: () -> Unit = {}, onScrollDirectionChange: (isScrollingUp: Boolean) -> Unit = {}, @@ -212,7 +250,22 @@ fun ChatScreen( 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 @@ -234,18 +287,24 @@ fun ChatScreen( state = recoveryFabTooltipState, hasAction = true, ) { - RecoveryFab( + RecoveryFabs( isFullscreen = isFullscreen, showInput = showInput, onRecover = onRecover, + fabMenuCallbacks = fabMenuCallbacks, + menuExpanded = fabMenuExpanded, + onMenuExpandedChange = { fabMenuExpanded = it }, modifier = Modifier.padding(bottom = recoveryBottomPadding), ) } } else { - RecoveryFab( + RecoveryFabs( isFullscreen = isFullscreen, showInput = showInput, onRecover = onRecover, + fabMenuCallbacks = fabMenuCallbacks, + menuExpanded = fabMenuExpanded, + onMenuExpandedChange = { fabMenuExpanded = it }, modifier = Modifier.padding(bottom = recoveryBottomPadding), ) } @@ -273,33 +332,245 @@ fun ChatScreen( } } +@Stable +class FabMenuCallbacks( + val onAction: (InputAction) -> Unit, + val isStreamActive: Boolean, + val hasStreamData: Boolean, + val isFullscreen: Boolean, + val isModerator: Boolean, + val debugMode: Boolean, + val enabled: Boolean, + val hasLastMessage: Boolean, +) + @Composable -private fun RecoveryFab( +private fun RecoveryFabs( isFullscreen: Boolean, showInput: Boolean, onRecover: () -> Unit, + fabMenuCallbacks: FabMenuCallbacks?, + menuExpanded: Boolean, + onMenuExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val visible = isFullscreen || !showInput + AnimatedVisibility( visible = visible, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut(), modifier = modifier, ) { - SmallFloatingActionButton( - onClick = onRecover, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( - imageVector = Icons.Default.FullscreenExit, - contentDescription = stringResource(R.string.menu_exit_fullscreen), - ) + // More FAB ↔ Actions menu — single animated slot + if (!showInput && fabMenuCallbacks != null) { + 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, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } + } + } + + SmallFloatingActionButton( + onClick = { + onMenuExpandedChange(false) + onRecover() + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f), + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = stringResource(R.string.menu_exit_fullscreen), + ) + } } } } +@Composable +private fun FabActionsMenu( + callbacks: FabMenuCallbacks, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val configuration = LocalConfiguration.current + val menuMaxHeight = (configuration.screenHeightDp * 0.35f).dp + val scrollState = rememberScrollState() + + 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), + ) { + 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 + } + + DropdownMenuItem( + text = { Text(stringResource(item.labelRes)) }, + onClick = { + callbacks.onAction(action) + onDismiss() + }, + enabled = actionEnabled, + leadingIcon = { + Icon( + imageVector = item.icon, + contentDescription = null, + ) + }, + ) + } + } + if (scrollState.maxValue > 0) { + 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 -> { + FabMenuItem(R.string.input_action_last_message, Icons.Default.History) + } + + 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 -> { + null + } + + // Already hidden, no point showing this + InputAction.Debug -> { + when { + debugMode -> FabMenuItem(R.string.input_action_debug, Icons.Default.BugReport) + else -> null + } + } + } + private val HIGHLIGHT_CORNER_RADIUS = 8.dp private fun ChatMessageUiState.highlightShape( 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 index c1c3a0ed4..077fc25f5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -58,6 +58,7 @@ 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.mention.MentionViewModel import com.flxrs.dankchat.ui.chat.swipeDownToHide @@ -668,6 +669,60 @@ fun MainScreen( } // 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(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() + } + + InputAction.Debug -> { + sheetNavigationViewModel.openDebugInfo() + } + } + } + } + val fabMenuCallbacks = + FabMenuCallbacks( + onAction = fabActionHandler, + isStreamActive = currentStream != null, + 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, @@ -687,6 +742,7 @@ fun MainScreen( scrollTargets = scrollTargets.toImmutableMap(), onClearScrollTarget = { scrollTargets.remove(it) }, callbacks = chatPagerCallbacks, + fabMenuCallbacks = fabMenuCallbacks, currentTourStep = featureTourState.currentTourStep, recoveryFabTooltipState = featureTourViewModel.recoveryFabTooltipState, onAddChannel = dialogViewModel::showAddChannel, 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 index 0af879441..59e12c5d3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -27,6 +27,7 @@ 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 @@ -74,6 +75,7 @@ internal fun MainScreenPagerContent( scrollTargets: ImmutableMap, onClearScrollTarget: (UserName) -> Unit, callbacks: ChatPagerCallbacks, + fabMenuCallbacks: FabMenuCallbacks?, currentTourStep: TourStep?, recoveryFabTooltipState: TooltipState?, onAddChannel: () -> Unit, @@ -157,6 +159,7 @@ internal fun MainScreenPagerContent( isFullscreen = isFullscreen, showFabs = !isSheetOpen, onRecover = callbacks.onRecover, + fabMenuCallbacks = fabMenuCallbacks, contentPadding = PaddingValues( top = chatTopPadding + 56.dp, 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 index a789e4a63..d20aee853 100644 --- 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 @@ -120,7 +120,7 @@ fun ChatBottomBar( val style = MaterialTheme.typography.labelSmall val density = LocalDensity.current Surface( - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.85f), modifier = Modifier .fillMaxWidth() 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 index a13624839..82121af4a 100644 --- 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 @@ -101,6 +101,7 @@ 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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -437,13 +438,7 @@ fun ChatInputLayout( } } QuickActionsMenu( - modifier = - Modifier.graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - }, + modifier = Modifier.predictiveBackScale(backProgress), surfaceColor = surfaceColor, visibleActions = visibleActions, enabled = enabled, 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..198e50d0b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/PredictiveBackModifier.kt @@ -0,0 +1,12 @@ +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) + } From 957884a33b1b7a3a1dc41170088773193043344c Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 14:34:31 +0200 Subject: [PATCH 175/349] fix(toolbar): Fix predictive back showing opaque background on toolbar menus, use shared predictiveBackScale modifier --- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 34 +-- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 224 +++++++++--------- 2 files changed, 125 insertions(+), 133 deletions(-) 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 index 28931766a..ac781a25b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -102,6 +102,7 @@ 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.utils.compose.predictiveBackScale import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first import kotlin.coroutines.cancellation.CancellationException @@ -423,13 +424,7 @@ fun FloatingToolbar( Surface( shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainer, - modifier = - Modifier.graphicsLayer { - val scale = 1f - (quickSwitchBackProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - quickSwitchBackProgress - }, + modifier = Modifier.predictiveBackScale(quickSwitchBackProgress), ) { PredictiveBackHandler { progress -> try { @@ -631,21 +626,16 @@ fun FloatingToolbar( .padding(top = 4.dp) .endAlignedOverflow(), ) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - InlineOverflowMenu( - isLoggedIn = isLoggedIn, - onDismiss = { - showOverflowMenu = false - overflowInitialMenu = AppBarMenu.Main - }, - initialMenu = overflowInitialMenu, - onAction = onAction, - keyboardHeightDp = keyboardHeightDp, - ) - } + InlineOverflowMenu( + isLoggedIn = isLoggedIn, + onDismiss = { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + }, + initialMenu = overflowInitialMenu, + onAction = onAction, + keyboardHeightDp = keyboardHeightDp, + ) } } } 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 index fa1105b0d..80d31da05 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -44,6 +44,7 @@ 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 @@ -68,6 +69,7 @@ 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 @@ -120,138 +122,138 @@ fun InlineOverflowMenu( scrollState.scrollTo(0) } - ScrollArea( - state = scrollAreaState, - modifier = - Modifier - .width(200.dp) - .heightIn(max = maxHeight) - .graphicsLayer { - val scale = 1f - (backProgress * 0.1f) - scaleX = scale - scaleY = scale - alpha = 1f - backProgress - }, + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.predictiveBackScale(backProgress), ) { - 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 -> { - if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { - onAction(ToolbarAction.Login) + ScrollArea( + state = scrollAreaState, + modifier = + Modifier + .width(200.dp) + .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 -> { + if (!isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { + onAction(ToolbarAction.Login) + onDismiss() + } + } else { + InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { + onAction(ToolbarAction.Relogin) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { + onAction(ToolbarAction.Logout) + onDismiss() + } + } + + HorizontalDivider() + + InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { + onAction(ToolbarAction.ManageChannels) onDismiss() } - } else { - InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { - onAction(ToolbarAction.Relogin) + InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { + onAction(ToolbarAction.RemoveChannel) onDismiss() } - InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { - onAction(ToolbarAction.Logout) + InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { + onAction(ToolbarAction.ReloadEmotes) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { + onAction(ToolbarAction.Reconnect) onDismiss() } - } - - HorizontalDivider() - - InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { - onAction(ToolbarAction.ManageChannels) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { - onAction(ToolbarAction.RemoveChannel) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { - onAction(ToolbarAction.ReloadEmotes) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { - onAction(ToolbarAction.Reconnect) - onDismiss() - } - HorizontalDivider() + HorizontalDivider() - InlineMenuItem(text = stringResource(R.string.upload_media), icon = Icons.Default.CloudUpload, hasSubMenu = true) { currentMenu = AppBarMenu.Upload } - InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true) { currentMenu = AppBarMenu.Channel } + InlineMenuItem(text = stringResource(R.string.upload_media), icon = Icons.Default.CloudUpload, hasSubMenu = true) { currentMenu = AppBarMenu.Upload } + InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true) { currentMenu = AppBarMenu.Channel } - HorizontalDivider() + HorizontalDivider() - InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { - onAction(ToolbarAction.OpenSettings) - onDismiss() + InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { + onAction(ToolbarAction.OpenSettings) + onDismiss() + } } - } - AppBarMenu.Upload -> { - InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { - onAction(ToolbarAction.CaptureImage) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { - onAction(ToolbarAction.CaptureVideo) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { - onAction(ToolbarAction.ChooseMedia) - onDismiss() + AppBarMenu.Upload -> { + InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { + onAction(ToolbarAction.CaptureImage) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { + onAction(ToolbarAction.CaptureVideo) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { + onAction(ToolbarAction.ChooseMedia) + onDismiss() + } } - } - AppBarMenu.Channel -> { - InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { - onAction(ToolbarAction.OpenChannel) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { - onAction(ToolbarAction.ReportChannel) - onDismiss() - } - if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { - onAction(ToolbarAction.BlockChannel) + AppBarMenu.Channel -> { + InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) + InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { + onAction(ToolbarAction.OpenChannel) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { + onAction(ToolbarAction.ReportChannel) onDismiss() } + if (isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { + onAction(ToolbarAction.BlockChannel) + onDismiss() + } + } } } } } - } - if (scrollState.maxValue > 0) { - 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), - ), - ) + if (scrollState.maxValue > 0) { + 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), + ), + ) + } } } } From 0d02883d302f45af1c747010040272f3522dea0d Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 14:54:04 +0200 Subject: [PATCH 176/349] refactor(compose): Replace onGloballyPositioned with onSizeChanged for size-only usages --- .../main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 8 ++++---- .../com/flxrs/dankchat/ui/main/input/ChatBottomBar.kt | 8 ++++---- .../flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) 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 index 077fc25f5..5460532e8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -43,7 +43,7 @@ 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.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -784,7 +784,7 @@ fun MainScreen( modifier = Modifier .fillMaxSize() - .onGloballyPositioned { containerWidthPx = it.size.width }, + .onSizeChanged { containerWidthPx = it.width }, ) { Row(modifier = Modifier.fillMaxSize()) { // Left pane: Stream @@ -955,8 +955,8 @@ fun MainScreen( .align(Alignment.TopCenter) .fillMaxWidth() .graphicsLayer { alpha = streamState.alpha.value } - .onGloballyPositioned { coordinates -> - streamState.heightDp = with(density) { coordinates.size.height.toDp() } + .onSizeChanged { size -> + streamState.heightDp = with(density) { size.height.toDp() } } }, ) 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 index d20aee853..25e1f7eac 100644 --- 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 @@ -22,7 +22,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.rememberTextMeasurer @@ -86,8 +86,8 @@ fun ChatBottomBar( tourState = tourState, isRepeatedSendEnabled = isRepeatedSendEnabled, modifier = - Modifier.onGloballyPositioned { coordinates -> - onInputHeightChange(coordinates.size.height) + Modifier.onSizeChanged { size -> + onInputHeightChange(size.height) }, ) } @@ -124,7 +124,7 @@ fun ChatBottomBar( modifier = Modifier .fillMaxWidth() - .onGloballyPositioned { onHelperTextHeightChange(it.size.height) }, + .onSizeChanged { onHelperTextHeightChange(it.height) }, ) { BoxWithConstraints( modifier = 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 index 65ba3438d..eb22ebca8 100644 --- 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 @@ -52,7 +52,7 @@ 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.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -263,8 +263,8 @@ fun MessageHistorySheet( .fillMaxWidth() .padding(bottom = currentImeDp) .navigationBarsPadding() - .onGloballyPositioned { coordinates -> - searchBarHeightPx = coordinates.size.height + .onSizeChanged { size -> + searchBarHeightPx = size.height }.padding(bottom = 8.dp) .padding(horizontal = 8.dp), ) { From db8a0aeefe5eff6d8afc21dffa81368ea96158dc Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 14:59:36 +0200 Subject: [PATCH 177/349] fix(ui): Add slight opacity to split pane draggable handle background --- .../main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4b95a277e..c0c138d1f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt @@ -37,7 +37,7 @@ fun DraggableHandle( .width(16.dp) .height(56.dp) .background( - color = MaterialTheme.colorScheme.surfaceContainerHighest, + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.85f), shape = RoundedCornerShape(8.dp), ), contentAlignment = Alignment.Center, From 63f2189b2399bb57953c6274d9ecf8b0d69d67f2 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 15:05:19 +0200 Subject: [PATCH 178/349] feat(input): Make helper text expand to two lines on tap instead of always showing two lines --- .../dankchat/ui/main/input/ChatBottomBar.kt | 29 ++++++++++++------- .../dankchat/ui/main/input/ChatInputLayout.kt | 25 +++++++++------- 2 files changed, 32 insertions(+), 22 deletions(-) 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 index 25e1f7eac..74baed70c 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -20,7 +21,10 @@ 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 @@ -126,11 +130,13 @@ fun ChatBottomBar( .fillMaxWidth() .onSizeChanged { onHelperTextHeightChange(it.height) }, ) { + var expanded by remember { mutableStateOf(false) } BoxWithConstraints( modifier = Modifier .navigationBarsPadding() .fillMaxWidth() + .clickable { expanded = !expanded } .padding(horizontalPadding) .padding(vertical = 6.dp) .animateContentSize(), @@ -140,18 +146,9 @@ fun ChatBottomBar( remember(combinedText, style, maxWidthPx) { textMeasurer.measure(combinedText, style).size.width <= maxWidthPx } + val showTwoLines = expanded && !fitsOnOneLine && streamInfoText != null && roomStateText.isNotEmpty() when { - fitsOnOneLine || streamInfoText == null || roomStateText.isEmpty() -> { - Text( - text = combinedText, - style = style, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), - ) - } - - else -> { + showTwoLines -> { Column { Text( text = roomStateText, @@ -169,6 +166,16 @@ fun ChatBottomBar( ) } } + + 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/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt index 82121af4a..ba920b337 100644 --- 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 @@ -308,10 +308,12 @@ fun ChatInputLayout( val textMeasurer = rememberTextMeasurer() val style = MaterialTheme.typography.labelSmall val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } BoxWithConstraints( modifier = Modifier .fillMaxWidth() + .clickable { expanded = !expanded } .padding(horizontal = 16.dp) .padding(bottom = 4.dp) .animateContentSize(), @@ -321,18 +323,9 @@ fun ChatInputLayout( remember(combinedText, style, maxWidthPx) { textMeasurer.measure(combinedText, style).size.width <= maxWidthPx } + val showTwoLines = expanded && !fitsOnOneLine && streamInfoText != null && roomStateText.isNotEmpty() when { - fitsOnOneLine || streamInfoText == null || roomStateText.isEmpty() -> { - Text( - text = combinedText, - style = style, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), - ) - } - - else -> { + showTwoLines -> { Column { Text( text = roomStateText, @@ -350,6 +343,16 @@ fun ChatInputLayout( ) } } + + else -> { + Text( + text = combinedText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), + ) + } } } } From 853366733032048481f3a62aef87b066c9f37d2d Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 16:06:41 +0200 Subject: [PATCH 179/349] fix(toolbar): Long press tab row opens manage channels, fix tour tooltip pointing at escape FAB --- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 109 ++++++++++-------- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 7 +- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 5 +- .../flxrs/dankchat/ui/main/ToolbarAction.kt | 4 +- .../ui/main/dialog/ManageChannelsDialog.kt | 2 +- 5 files changed, 67 insertions(+), 60 deletions(-) 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 index 4965a4c49..998dba3c3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -273,41 +273,24 @@ fun ChatScreen( .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + fabBottomPadding), contentAlignment = Alignment.BottomEnd, ) { - if (recoveryFabTooltipState != null) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - TourTooltip( - text = stringResource(R.string.tour_recovery_fab), - onAction = { onTourAdvance?.invoke() }, - onSkip = { onTourSkip?.invoke() }, - isLast = true, - ) - }, - state = recoveryFabTooltipState, - hasAction = true, - ) { - RecoveryFabs( - isFullscreen = isFullscreen, - showInput = showInput, - onRecover = onRecover, - fabMenuCallbacks = fabMenuCallbacks, - menuExpanded = fabMenuExpanded, - onMenuExpandedChange = { fabMenuExpanded = it }, - modifier = Modifier.padding(bottom = recoveryBottomPadding), - ) - } - } else { - RecoveryFabs( - isFullscreen = isFullscreen, - showInput = showInput, - onRecover = onRecover, - fabMenuCallbacks = fabMenuCallbacks, - menuExpanded = fabMenuExpanded, - onMenuExpandedChange = { fabMenuExpanded = it }, - modifier = Modifier.padding(bottom = recoveryBottomPadding), - ) - } + 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(), @@ -344,6 +327,7 @@ class FabMenuCallbacks( val hasLastMessage: Boolean, ) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RecoveryFabs( isFullscreen: Boolean, @@ -353,6 +337,9 @@ private fun RecoveryFabs( menuExpanded: Boolean, onMenuExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + recoveryFabTooltipState: TooltipState? = null, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, ) { val visible = isFullscreen || !showInput @@ -411,18 +398,46 @@ private fun RecoveryFabs( } } - SmallFloatingActionButton( - onClick = { - onMenuExpandedChange(false) - onRecover() - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f), - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) { - Icon( - imageVector = Icons.Default.FullscreenExit, - contentDescription = stringResource(R.string.menu_exit_fullscreen), - ) + val escapeFab: @Composable () -> Unit = { + SmallFloatingActionButton( + onClick = { + onMenuExpandedChange(false) + onRecover() + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f), + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = stringResource(R.string.menu_exit_fullscreen), + ) + } + } + + if (recoveryFabTooltipState != null) { + Box(modifier = Modifier.align(Alignment.End)) { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Start, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + TourTooltip( + text = stringResource(R.string.tour_recovery_fab), + onAction = { onTourAdvance?.invoke() }, + onSkip = { onTourSkip?.invoke() }, + isLast = true, + ) + }, + state = recoveryFabTooltipState, + hasAction = true, + ) { + escapeFab() + } + } + } else { + escapeFab() } } } 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 index ac781a25b..e6d04bd56 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -337,12 +337,7 @@ fun FloatingToolbar( Modifier .combinedClickable( onClick = { onAction(ToolbarAction.SelectTab(index)) }, - onLongClick = { - showQuickSwitch = false - onAction(ToolbarAction.LongClickTab(index)) - overflowInitialMenu = AppBarMenu.Main - showOverflowMenu = true - }, + onLongClick = { onAction(ToolbarAction.LongClickTab) }, ).defaultMinSize(minHeight = 48.dp) .padding(horizontal = 12.dp) .onGloballyPositioned { coords -> 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 index 5460532e8..047b8d829 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -536,9 +536,8 @@ fun MainScreen( scope.launch { composePagerState.scrollToPage(action.index) } } - is ToolbarAction.LongClickTab -> { - channelTabViewModel.selectTab(action.index) - scope.launch { composePagerState.scrollToPage(action.index) } + ToolbarAction.LongClickTab -> { + dialogViewModel.showManageChannels() } ToolbarAction.AddChannel -> { 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 index 35aacc021..2c6dc773a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt @@ -8,9 +8,7 @@ sealed interface ToolbarAction { val index: Int, ) : ToolbarAction - data class LongClickTab( - val index: Int, - ) : ToolbarAction + data object LongClickTab : ToolbarAction data object AddChannel : ToolbarAction 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 index e5b94bdca..236b2611e 100644 --- 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 @@ -379,7 +379,7 @@ private fun DeleteChannelConfirmation( .padding(bottom = 16.dp), ) { Text( - text = stringResource(R.string.confirm_channel_removal_message), + text = stringResource(R.string.confirm_channel_removal_message_named, channelName.value), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, From 0b7754e13f4b52fc10d32e7df6b5230dc5dd1e89 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 16:07:07 +0200 Subject: [PATCH 180/349] style: Apply spotless formatting to build script --- app/build.gradle.kts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 608da57f9..befb620c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,9 @@ import com.android.build.api.artifact.ArtifactTransformationRequest import com.android.build.api.artifact.SingleArtifact import com.android.build.gradle.internal.PropertiesValueSource +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.StringReader import java.util.Properties -import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) @@ -79,7 +79,11 @@ android { 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) + val transformationRequest = + variant.artifacts + .use(renameTask) + .wiredWithDirectories(RenameApkTask::inputDirs, RenameApkTask::outputDirs) + .toTransformMany(SingleArtifact.APK) renameTask.configure { this.transformationRequest = transformationRequest } } @@ -238,12 +242,14 @@ spotless { 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", - )) + .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", + ), + ) } kotlinGradle { target("*.gradle.kts") @@ -257,7 +263,10 @@ detekt { parallel = true } -fun gradleLocalProperties(projectRootDir: File, providers: ProviderFactory): Properties { +fun gradleLocalProperties( + projectRootDir: File, + providers: ProviderFactory, +): Properties { val properties = Properties() val propertiesContent = providers.of(PropertiesValueSource::class.java) { parameters.projectRoot.set(projectRootDir) }.get() From 3eabeffcb579d2f191f71a660cd6f67f55d32b28 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 16:52:05 +0200 Subject: [PATCH 181/349] refactor(suggestions): Unify emote and user scoring, add typed combine for 6 flows, make AuthEvent when exhaustive --- .../ui/chat/suggestion/SuggestionProvider.kt | 51 ++++++++++++++----- .../ui/main/MainScreenEventHandler.kt | 6 +-- .../dankchat/ui/main/MainScreenViewModel.kt | 1 - .../ui/main/input/ChatInputViewModel.kt | 11 +--- .../utils/extensions/FlowExtensions.kt | 22 ++++++++ .../suggestion/SuggestionFilteringTest.kt | 37 ++++++++++++++ 6 files changed, 102 insertions(+), 26 deletions(-) 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 index f175b903d..a79c37ae8 100644 --- 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 @@ -58,31 +58,45 @@ class SuggestionProvider( } } + // '@' trigger: users only + if (currentWord.startsWith('@')) { + return getUserSuggestions(channel, currentWord).map { users -> + users.take(MAX_SUGGESTIONS) + } + } + + // Commands only when prefix matches a command trigger character + val isCommandTrigger = currentWord.startsWith('/') || currentWord.startsWith('$') + if (isCommandTrigger) { + return getCommandSuggestions(channel, currentWord).map { commands -> + commands.take(MAX_SUGGESTIONS) + } + } + + // General: score emotes + users together, emotes slightly preferred return combine( - getEmoteSuggestions(channel, currentWord), - getUserSuggestions(channel, currentWord), - getCommandSuggestions(channel, currentWord), - ) { emotes, users, commands -> - (emotes + users + commands).take(MAX_SUGGESTIONS) + getScoredEmoteSuggestions(channel, currentWord), + getScoredUserSuggestions(channel, currentWord), + ) { emotes, users -> + mergeSorted(emotes, users) } } - private fun getEmoteSuggestions( + private fun getScoredEmoteSuggestions( channel: UserName, constraint: String, - ): Flow> = + ): Flow> = emoteRepository.getEmotes(channel).map { emotes -> val recentIds = emoteUsageRepository.recentEmoteIds.value - filterEmotes(emotes.suggestions, constraint, recentIds) + filterEmotesScored(emotes.suggestions, constraint, recentIds) } - private fun getScoredEmoteSuggestions( + private fun getScoredUserSuggestions( channel: UserName, constraint: String, ): Flow> = - emoteRepository.getEmotes(channel).map { emotes -> - val recentIds = emoteUsageRepository.recentEmoteIds.value - filterEmotesScored(emotes.suggestions, constraint, recentIds) + usersRepository.getUsersFlow(channel).map { displayNameSet -> + filterUsersScored(displayNameSet, constraint) } private fun getUserSuggestions( @@ -188,7 +202,7 @@ class SuggestionProvider( if (score == NO_MATCH) null else ScoredSuggestion(Suggestion.EmojiSuggestion(emoji), score) }.sortedBy { it.score } - // Filter raw DisplayName set, only wrap matches + // Filter raw DisplayName set, only wrap matches — used for @-prefix suggestions internal fun filterUsers( users: Set, constraint: String, @@ -201,6 +215,16 @@ class SuggestionProvider( }.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, @@ -213,6 +237,7 @@ class SuggestionProvider( 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 } 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 index 6381f2b1d..36dd25388 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -121,9 +121,9 @@ fun MainScreenEventHandler( ) } - else -> { - Unit - } + is AuthEvent.ScopesOutdated -> {} + + AuthEvent.TokenInvalid -> {} } } } 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 index ffa7da47e..d3378f89a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -36,7 +36,6 @@ import org.koin.android.annotation.KoinViewModel * - ChatInputViewModel - Input state * - ChannelManagementViewModel - Channel operations * - * This ViewModel only handles truly global concerns. */ @OptIn(FlowPreview::class) @KoinViewModel 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 index 6c5ad0d59..3928131f8 100644 --- 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 @@ -33,6 +33,7 @@ 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 @@ -248,15 +249,7 @@ class ChatInputViewModel( _isEmoteMenuOpen, _whisperTarget, _isAnnouncing, - ) { values -> - val sheetState = values[0] as FullScreenSheetState - val tab = values[1] as Int - - @Suppress("UNCHECKED_CAST") - val replyState = values[2] as Triple - val isEmoteMenuOpen = values[3] as Boolean - val whisperTarget = values[4] as UserName? - val isAnnouncing = values[5] as Boolean + ) { sheetState, tab, replyState, isEmoteMenuOpen, whisperTarget, isAnnouncing -> val (isReplying, replyName, replyMessageId) = replyState InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget, isAnnouncing) } 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 abbeec82e..4f4c0f467 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 @@ -5,6 +5,7 @@ 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 @@ -52,6 +53,27 @@ fun MutableSharedFlow>.clear(key: UserName) = }, ) +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, 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 index c147cbcc4..41c5e0172 100644 --- 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 @@ -7,6 +7,7 @@ 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 = @@ -123,6 +124,42 @@ internal class SuggestionFilteringTest { // 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 From 17fe31d4ed3867563c6cb0acc6643b1be01e2c09 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 17:25:32 +0200 Subject: [PATCH 182/349] fix(tour): Fix recovery FAB tooltip caret, complete tour on FAB click, prevent outside dismiss --- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 148 +++++++++++++----- .../dankchat/ui/main/QuickActionsMenu.kt | 40 +---- .../StartAlignedTooltipPositionProvider.kt | 44 ++++++ 3 files changed, 151 insertions(+), 81 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StartAlignedTooltipPositionProvider.kt 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 index 998dba3c3..2e4034a99 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -25,6 +25,7 @@ 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.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -97,6 +98,7 @@ 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 com.flxrs.dankchat.utils.compose.rememberStartAlignedTooltipPositionProvider data class ChatScreenCallbacks( val onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, @@ -343,18 +345,32 @@ private fun RecoveryFabs( ) { val visible = isFullscreen || !showInput - AnimatedVisibility( - visible = visible, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - modifier = modifier, - ) { + val escapeFab: @Composable () -> Unit = { + SmallFloatingActionButton( + onClick = { + onMenuExpandedChange(false) + onTourAdvance?.invoke() + onRecover() + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f), + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = stringResource(R.string.menu_exit_fullscreen), + ) + } + } + + // TooltipBox must be outside AnimatedVisibility — the scaleIn animation + // transforms anchor bounds, causing M3 to miscalculate the caret position. + val tooltipContent: @Composable () -> Unit = { Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - // More FAB ↔ Actions menu — single animated slot - if (!showInput && fabMenuCallbacks != null) { + // More FAB ↔ Actions menu — hidden during tour so tooltip points at escape FAB + if (!showInput && fabMenuCallbacks != null && recoveryFabTooltipState == null) { AnimatedContent( targetState = menuExpanded, transitionSpec = { @@ -398,45 +414,93 @@ private fun RecoveryFabs( } } - val escapeFab: @Composable () -> Unit = { - SmallFloatingActionButton( - onClick = { - onMenuExpandedChange(false) - onRecover() - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f), - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) { - Icon( - imageVector = Icons.Default.FullscreenExit, - contentDescription = stringResource(R.string.menu_exit_fullscreen), - ) - } + AnimatedVisibility( + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + escapeFab() } + } + } - if (recoveryFabTooltipState != null) { - Box(modifier = Modifier.align(Alignment.End)) { - TooltipBox( - positionProvider = - TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Start, - spacingBetweenTooltipAndAnchor = 8.dp, - ), - tooltip = { - TourTooltip( - text = stringResource(R.string.tour_recovery_fab), - onAction = { onTourAdvance?.invoke() }, - onSkip = { onTourSkip?.invoke() }, - isLast = true, - ) + 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, + ) + }, + state = recoveryFabTooltipState, + onDismissRequest = {}, + hasAction = true, + modifier = modifier, + ) { + tooltipContent() + } + } 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) { + AnimatedContent( + targetState = menuExpanded, + transitionSpec = { + (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) }, - state = recoveryFabTooltipState, - hasAction = true, - ) { - escapeFab() + 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, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } } } - } else { + escapeFab() } } 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 index b88ac53fd..a3b722977 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -50,6 +50,7 @@ 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 /** @@ -298,42 +299,3 @@ private fun EndCaretTourTooltip( } } } - -/** - * Positions the tooltip to the start (left in LTR) of the anchor, vertically centered. - * Falls back to above-positioning if there's not enough horizontal space. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private 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) { - // Fits to the start — vertically center on anchor - val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 - IntOffset( - startX, - y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)), - ) - } else { - // Not enough space — fall back to above, horizontally end-aligned with anchor - 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/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) + } + } + } + } +} From 86edd85c9d817e4301733d6f3a1679450282b0c5 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 17:25:48 +0200 Subject: [PATCH 183/349] fix: Manual review fixes for SevenTV, DankBackground, preference dialogs, and toolbar --- .../api/seventv/dto/SevenTVUserConnection.kt | 4 ++- .../preferences/components/DankBackground.kt | 7 +++-- .../components/PreferenceListDialog.kt | 30 +++++++++---------- .../components/PreferenceMultiListDialog.kt | 18 +++++------ .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 2 +- 5 files changed, 32 insertions(+), 29 deletions(-) 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/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/PreferenceListDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceListDialog.kt index 581433b79..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 @@ -52,21 +52,21 @@ fun PreferenceListDialog( val interactionSource = remember { MutableInteractionSource() } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selected == value, - onClick = { - onChange(value) - 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 == value, 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 903c0359d..7d4aa78a9 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 @@ -58,15 +58,15 @@ fun PreferenceMultiListDialog( 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, 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 index e6d04bd56..e88664aec 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -184,7 +184,7 @@ fun FloatingToolbar( .graphicsLayer { alpha = streamToolbarAlpha }, ) { val scrimColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - val statusBarPx = with(density) { WindowInsets.statusBars.getTop(density).toFloat() } + val statusBarPx = WindowInsets.statusBars.getTop(density).toFloat() var toolbarRowHeight by remember { mutableFloatStateOf(0f) } val scrimModifier = if (hasStream) { From 071e76b48d435382623e455030a63b31c3cfafbe Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 17:43:03 +0200 Subject: [PATCH 184/349] fix(chat): Use localized full duration format for timeout and ban messages --- .../data/twitch/message/ModerationMessage.kt | 78 +++++++++---------- .../dankchat/ui/chat/ChatMessageMapper.kt | 2 +- .../dankchat/ui/chat/ChatMessageUiState.kt | 2 +- .../ui/chat/messages/SystemMessages.kt | 19 ++++- 4 files changed, 59 insertions(+), 42 deletions(-) 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 40fbab34a..8fdf76405 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 @@ -31,7 +31,7 @@ data class ModerationMessage( val sourceBroadcasterDisplay: DisplayName? = null, val targetMsgId: String? = null, val durationInt: Int? = null, - val duration: String? = null, + val duration: TextResource? = null, val reason: String? = null, val fromEventSource: Boolean = false, val stackCount: Int = 0, @@ -89,46 +89,13 @@ data class ModerationMessage( else -> TextResource.Plain("") } - private 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)) }) - } - - private 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 getSystemMessage( currentUser: UserName?, showDeletedMessage: Boolean, ): TextResource { val creator = creatorUserDisplay.toString() val target = targetUserDisplay.toString() - val dur = duration.orEmpty() + val dur: Any = duration ?: "" val source = sourceBroadcasterDisplay.toString() val message = @@ -368,12 +335,45 @@ data class ModerationMessage( val canStack: Boolean = canClearMessages && action != 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 duration = durationSeconds?.let(::formatSecondsDuration) val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() val id = tags["id"] ?: "clearchat-$ts-$channel-${target ?: "all"}" val action = @@ -457,7 +457,7 @@ data class 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 formattedDuration = duration?.let(::formatSecondsDuration) val userPair = parseTargetUser(data) val targetMsgId = parseTargetMsgId(data) val reason = parseReason(data) @@ -482,9 +482,9 @@ data class ModerationMessage( private fun parseDuration( seconds: Int?, data: ModerationActionData, - ): String? = + ): TextResource? = when (data.moderationAction) { - ModerationActionType.Timeout -> seconds?.let { DateTimeUtils.formatSeconds(seconds) } + ModerationActionType.Timeout -> seconds?.let(::formatSecondsDuration) else -> null } 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 index 3d5513e48..6db4eb1d9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -372,7 +372,7 @@ class ChatMessageMapper( } val arguments = - buildList { + buildList { duration?.let(::add) reason?.takeIf { it.isNotBlank() }?.let(::add) sourceBroadcasterDisplay?.toString()?.let(::add) 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 index d45317622..d971d60a2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -125,7 +125,7 @@ sealed interface ChatMessageUiState { val targetName: String? = null, val creatorColor: Int = Message.DEFAULT_COLOR, val targetColor: Int = Message.DEFAULT_COLOR, - val arguments: ImmutableList = persistentListOf(), + val arguments: ImmutableList = persistentListOf(), ) : ChatMessageUiState /** 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 index c7498c4d4..288a59bb4 100644 --- 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 @@ -31,6 +31,7 @@ import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor import com.flxrs.dankchat.ui.chat.rememberNormalizedColor +import com.flxrs.dankchat.utils.TextResource import com.flxrs.dankchat.utils.resolve /** @@ -221,10 +222,26 @@ fun ModerationMessageComposable( 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, @@ -250,7 +267,7 @@ fun ModerationMessageComposable( add(StyledRange(idx, name.length, targetColor, bold = true)) } } - for (arg in message.arguments) { + 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 }) { From 0e19eb413f79c7b790553a8c427e78fe4960149a Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 18:21:55 +0200 Subject: [PATCH 185/349] fix(sheets): Use surfaceContainerLow for toolbar pills on fullscreen sheets for better contrast --- .../kotlin/com/flxrs/dankchat/ui/main/sheet/MentionSheet.kt | 5 +++-- .../com/flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt | 5 +++-- .../kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) 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 index 3729097f8..8e423eba7 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -172,7 +173,7 @@ fun MentionSheet( ) { Surface( shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, ) { IconButton(onClick = onDismiss) { Icon( @@ -184,7 +185,7 @@ fun MentionSheet( Surface( shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, ) { Row { val tabs = listOf(R.string.mentions, R.string.whispers) 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 index eb22ebca8..233239b99 100644 --- 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 @@ -6,6 +6,7 @@ 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.Arrangement import androidx.compose.foundation.layout.Box @@ -201,7 +202,7 @@ fun MessageHistorySheet( ) { Surface( shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, ) { IconButton(onClick = onDismiss) { Icon( @@ -213,7 +214,7 @@ fun MessageHistorySheet( Surface( shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, ) { Row( verticalAlignment = Alignment.CenterVertically, 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 index 80299f925..68b11607f 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -148,7 +149,7 @@ fun RepliesSheet( ) { Surface( shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, ) { IconButton(onClick = onDismiss) { Icon( @@ -160,7 +161,7 @@ fun RepliesSheet( Surface( shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, + color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.padding(start = 8.dp), ) { Text( From 9d2d8ad11239c142d4999bb4d7878460daee967d Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 19:11:09 +0200 Subject: [PATCH 186/349] fix(highlights): Improve highlight colors for both themes, treat default colors as non-custom, remove XML color resources --- .../highlights/HighlightsScreen.kt | 21 +++--- .../flxrs/dankchat/ui/chat/BackgroundColor.kt | 14 ---- .../dankchat/ui/chat/ChatMessageMapper.kt | 70 +++++++++++++++---- app/src/main/res/values-night/colors.xml | 8 --- app/src/main/res/values/colors.xml | 16 ----- 5 files changed, 69 insertions(+), 60 deletions(-) delete mode 100644 app/src/main/res/values-night/colors.xml delete mode 100644 app/src/main/res/values/colors.xml 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 4b076fe51..aec971c02 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,14 +75,15 @@ 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.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 @@ -468,14 +470,15 @@ private fun MessageHighlightItem( ) } } + val isDark = isSystemInDarkTheme() 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) + MessageHighlightItem.Type.Subscription, MessageHighlightItem.Type.Announcement -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.Subscription, 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, @@ -526,7 +529,7 @@ private fun UserHighlightItem( enabled = item.enabled && item.notificationsEnabled, ) } - val defaultColor = ContextCompat.getColor(LocalContext.current, R.color.color_mention_highlight) + val defaultColor = ChatMessageMapper.defaultHighlightColorInt(HighlightType.Username, isSystemInDarkTheme()) HighlightColorPicker( color = item.customColor ?: defaultColor, defaultColor = defaultColor, @@ -609,7 +612,7 @@ private fun BadgeHighlightItem( enabled = item.enabled && item.notificationsEnabled, ) } - val defaultColor = ContextCompat.getColor(LocalContext.current, R.color.color_mention_highlight) + val defaultColor = ChatMessageMapper.defaultHighlightColorInt(HighlightType.Username, isSystemInDarkTheme()) HighlightColorPicker( color = item.customColor ?: defaultColor, defaultColor = defaultColor, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt index b29b2c609..ef06524de 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt @@ -28,17 +28,3 @@ fun rememberBackgroundColor( } } } - -/** - * Returns the opaque checkered background color for the current theme. - * Composites [inverseSurface] at low alpha over [background], matching the - * old adapter's [MaterialColors.layer] behavior. - */ -@Composable -fun rememberCheckeredBackgroundColor(): Color { - val background = MaterialTheme.colorScheme.background - val inverseSurface = MaterialTheme.colorScheme.inverseSurface - return remember(background, inverseSurface) { - inverseSurface.copy(alpha = MaterialColors.ALPHA_DISABLED_LOW).compositeOver(background) - } -} 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 index 6db4eb1d9..82bebbacd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -778,8 +778,9 @@ class ChatMessageMapper( this.maxByOrNull { it.type.priority.value } ?: return BackgroundColors(Color.Transparent, Color.Transparent) - if (highlight.customColor != null) { - val color = Color(highlight.customColor) + val customColor = highlight.customColor + if (customColor != null && customColor !in DEFAULT_HIGHLIGHT_COLOR_INTS) { + val color = Color(customColor) return BackgroundColors(color, color) } @@ -787,19 +788,62 @@ class ChatMessageMapper( } companion object { - // Highlight colors - Light theme - private val COLOR_SUB_HIGHLIGHT_LIGHT = Color(0xFFD1C4E9) - private val COLOR_MENTION_HIGHLIGHT_LIGHT = Color(0xFFEF9A9A) - private val COLOR_REDEMPTION_HIGHLIGHT_LIGHT = Color(0xFF93F1FF) - private val COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFC2F18D) - private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFFFE087) + // 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) // Highlight colors - Dark theme - private val COLOR_SUB_HIGHLIGHT_DARK = Color(0xFF543589) - private val COLOR_MENTION_HIGHLIGHT_DARK = Color(0xFF773031) - private val COLOR_REDEMPTION_HIGHLIGHT_DARK = Color(0xFF004F57) - private val COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK = Color(0xFF2D5000) - private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK = Color(0xFF574500) + 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) + + fun defaultHighlightColorInt( + type: HighlightType, + isDark: Boolean, + ): Int = + when (type) { + HighlightType.Subscription, HighlightType.Announcement -> if (isDark) 0xFF6A45A0.toInt() else 0xFF7E57C2.toInt() + HighlightType.Username, HighlightType.Custom, HighlightType.Reply, HighlightType.Notification, HighlightType.Badge -> if (isDark) 0xFF8C3A3B.toInt() else 0xFFCF5050.toInt() + HighlightType.ChannelPointRedemption -> if (isDark) 0xFF00606B.toInt() else 0xFF458B93.toInt() + HighlightType.FirstMessage -> if (isDark) 0xFF3A6600.toInt() else 0xFF558B2F.toInt() + HighlightType.ElevatedMessage -> if (isDark) 0xFF6B5800.toInt() 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 + // 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) + ) // Checkered background colors private val CHECKERED_LIGHT = 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/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index 22fa806db..000000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - #D1C4E9 - - - #EF9A9A - - - #93f1ff - - - #c2f18d - - - #ffe087 - From d3dc885658191753f16c9f7d9b5873016b995523 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 19:49:15 +0200 Subject: [PATCH 187/349] fix(chat): Use adaptive link color that falls back to inversePrimary on highlighted backgrounds --- .../dankchat/ui/chat/AdaptiveTextColor.kt | 23 +++++++++++++++++++ .../dankchat/ui/chat/messages/PrivMessage.kt | 3 ++- .../ui/chat/messages/SystemMessages.kt | 5 ++-- .../ui/chat/messages/WhisperAndRedemption.kt | 3 ++- .../messages/common/SimpleMessageContainer.kt | 3 ++- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt index 4e9fc7e1f..ed28a09a6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt @@ -6,6 +6,7 @@ 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.google.android.material.color.MaterialColors @@ -46,6 +47,28 @@ fun rememberAdaptiveTextColor(backgroundColor: Color): Color { } } +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] 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 index 5111bebdb..0da1a836b 100644 --- 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 @@ -44,6 +44,7 @@ import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent 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.timestampSpanStyle +import com.flxrs.dankchat.ui.chat.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor import com.flxrs.dankchat.ui.chat.rememberNormalizedColor @@ -169,7 +170,7 @@ private fun PrivMessageText( val context = LocalPlatformContext.current val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) - val linkColor = MaterialTheme.colorScheme.primary + val linkColor = rememberAdaptiveLinkColor(backgroundColor) // Build annotated string with text content val annotatedString = 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 index 288a59bb4..a97077431 100644 --- 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 @@ -28,6 +28,7 @@ import com.flxrs.dankchat.ui.chat.appendWithLinks import com.flxrs.dankchat.ui.chat.messages.common.SimpleMessageContainer import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle +import com.flxrs.dankchat.ui.chat.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor import com.flxrs.dankchat.ui.chat.rememberNormalizedColor @@ -88,7 +89,7 @@ fun UserNoticeMessageComposable( ) { val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = MaterialTheme.colorScheme.onSurface - val linkColor = MaterialTheme.colorScheme.primary + val linkColor = rememberAdaptiveLinkColor(bgColor) val timestampColor = MaterialTheme.colorScheme.onSurface val nameColor = rememberNormalizedColor(message.rawNameColor, bgColor) val textSize = fontSize.sp @@ -218,7 +219,7 @@ fun ModerationMessageComposable( val textSize = fontSize.sp val resolvedMessage = message.message.resolve() val context = LocalContext.current - val linkColor = MaterialTheme.colorScheme.primary + val linkColor = rememberAdaptiveLinkColor(bgColor) val dimmedTextColor = textColor.copy(alpha = 0.7f) 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 index dd38bbaf9..7d3210ec2 100644 --- 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 @@ -43,6 +43,7 @@ import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent 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.timestampSpanStyle +import com.flxrs.dankchat.ui.chat.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor import com.flxrs.dankchat.ui.chat.rememberNormalizedColor @@ -116,7 +117,7 @@ private fun WhisperMessageText( val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) val senderColor = rememberNormalizedColor(message.rawSenderColor, backgroundColor) val recipientColor = rememberNormalizedColor(message.rawRecipientColor, backgroundColor) - val linkColor = MaterialTheme.colorScheme.primary + val linkColor = rememberAdaptiveLinkColor(backgroundColor) // Build annotated string with text content val annotatedString = 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 index ece95d643..75fdd1c5f 100644 --- 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import com.flxrs.dankchat.ui.chat.appendWithLinks +import com.flxrs.dankchat.ui.chat.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor @@ -41,7 +42,7 @@ fun SimpleMessageContainer( ) { val bgColor = rememberBackgroundColor(lightBackgroundColor, darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) - val linkColor = MaterialTheme.colorScheme.primary + val linkColor = rememberAdaptiveLinkColor(bgColor) val timestampColor = MaterialTheme.colorScheme.onSurface val context = LocalContext.current From 4276eef4d6efa1941cfdbf8b9578d261d5c43afc Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 19:54:17 +0200 Subject: [PATCH 188/349] fix: Remove redundant Kotlin compiler arguments and .toInt() conversions --- app/build.gradle.kts | 2 -- .../dankchat/data/repo/HighlightsRepository.kt | 14 +++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index befb620c8..30757a09a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -124,8 +124,6 @@ kotlin { "-opt-in=kotlin.uuid.ExperimentalUuidApi", "-opt-in=kotlin.time.ExperimentalTime", "-opt-in=kotlin.concurrent.atomics.ExperimentalAtomicApi", - "-Xnon-local-break-continue", - "-Xwhen-guards", ) } } 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 5f2f9e15c..ed0ab17a0 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 @@ -418,13 +418,13 @@ class HighlightsRepository( ) 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 = "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), ) From 2efd41f674e67d2063c03b88ea037d7996a82314 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 20:34:21 +0200 Subject: [PATCH 189/349] refactor(compose): Replace LocalView and LocalConfiguration with LocalWindowInfo for screen height --- .../kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt | 10 ++++++++-- .../com/flxrs/dankchat/ui/main/FloatingToolbar.kt | 8 ++++++-- .../kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt | 8 ++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) 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 index 2e4034a99..792050c4e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration 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 androidx.compose.ui.unit.dp @@ -513,8 +514,13 @@ private fun FabActionsMenu( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val configuration = LocalConfiguration.current - val menuMaxHeight = (configuration.screenHeightDp * 0.35f).dp + val density = LocalDensity.current + val windowHeight = + with(density) { + LocalWindowInfo.current.containerSize.height + .toDp() + } + val menuMaxHeight = windowHeight * 0.35f val scrollState = rememberScrollState() Surface( 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 index e88664aec..007b9ad91 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -87,7 +87,7 @@ 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.LocalView +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 @@ -431,7 +431,11 @@ fun FloatingToolbar( quickSwitchBackProgress = 0f } } - val screenHeight = with(density) { LocalView.current.height.toDp() } + val screenHeight = + with(density) { + LocalWindowInfo.current.containerSize.height + .toDp() + } val maxMenuHeight = screenHeight * 0.3f val quickSwitchScrollState = rememberScrollState() val quickSwitchScrollAreaState = rememberScrollAreaState(quickSwitchScrollState) 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 index 80d31da05..f51e06bdb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -113,7 +113,11 @@ fun InlineOverflowMenu( } val density = LocalDensity.current - val screenHeight = with(density) { LocalView.current.height.toDp() } + val screenHeight = + with(density) { + LocalWindowInfo.current.containerSize.height + .toDp() + } val maxHeight = (screenHeight - keyboardHeightDp) * 0.4f val scrollState = rememberScrollState() val scrollAreaState = rememberScrollAreaState(scrollState) From 3a5ea21b3a2f400cde50b27740317299f37ae1e6 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 20:46:32 +0200 Subject: [PATCH 190/349] cleanup: Remove low-value KDoc that restates function names, describes migration rationale, or references old implementations --- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 10 ----- .../flxrs/dankchat/ui/chat/ChatMessageText.kt | 10 ----- .../dankchat/ui/chat/ChatMessageUiState.kt | 43 ------------------- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 7 --- .../ui/chat/EmoteAnimationCoordinator.kt | 20 --------- .../dankchat/ui/chat/EmoteDrawablePainter.kt | 5 --- .../flxrs/dankchat/ui/chat/EmoteScaling.kt | 42 ------------------ .../flxrs/dankchat/ui/chat/StackedEmote.kt | 19 -------- .../ui/chat/TextWithMeasuredInlineContent.kt | 25 ----------- .../dankchat/ui/main/MainScreenComponents.kt | 16 ------- .../ui/main/MainScreenPagerContent.kt | 6 --- .../dankchat/ui/main/MainScreenViewModel.kt | 10 ----- .../dankchat/ui/main/QuickActionsMenu.kt | 5 --- .../flxrs/dankchat/ui/theme/DankChatTheme.kt | 4 -- 14 files changed, 222 deletions(-) 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 index 6e01d62fb..556a0f985 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -16,16 +16,6 @@ import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -/** - * Standalone composable for chat display. - * Extracted from ChatFragment to enable pure Compose integration. - * - * This composable: - * - Creates its own ChatViewModel scoped to the channel - * - Collects messages from ViewModel - * - Collects settings from data stores - * - Renders ChatScreen with all event handlers - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatComposable( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt index f443b5617..dd8e9ecff 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt @@ -17,16 +17,6 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em -/** - * Renders a chat message text with support for: - * - Timestamps (monospace, bold) - * - Username colors - * - Emotes and badges (via InlineTextContent) - * - Clickable spans (usernames, links, emotes) - * - * NOTE: fontSize should come from appearanceSettings.fontSize, not be hardcoded - * NOTE: nameColor should come from the message's nameColor, not be hardcoded - */ @Composable fun ChatMessageText( text: String, 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 index d971d60a2..44faca226 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -13,10 +13,6 @@ import com.flxrs.dankchat.utils.TextResource import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -/** - * UI state for rendering chat messages in Compose. - * All rendering decisions are pre-computed to avoid work during recomposition. - */ @Immutable sealed interface ChatMessageUiState { val id: String @@ -28,9 +24,6 @@ sealed interface ChatMessageUiState { val enableRipple: Boolean val isHighlighted: Boolean - /** - * Regular chat message from a user - */ @Immutable data class PrivMessageUi( override val id: String, @@ -56,9 +49,6 @@ sealed interface ChatMessageUiState { val fullMessage: String, // For copying ) : ChatMessageUiState - /** - * System messages (connected, disconnected, etc.) - */ @Immutable data class SystemMessageUi( override val id: String, @@ -72,9 +62,6 @@ sealed interface ChatMessageUiState { val message: TextResource, ) : ChatMessageUiState - /** - * Notice messages from Twitch - */ @Immutable data class NoticeMessageUi( override val id: String, @@ -88,9 +75,6 @@ sealed interface ChatMessageUiState { val message: String, ) : ChatMessageUiState - /** - * User notice messages (subscriptions, etc.) - */ @Immutable data class UserNoticeMessageUi( override val id: String, @@ -107,9 +91,6 @@ sealed interface ChatMessageUiState { val shouldHighlight: Boolean, ) : ChatMessageUiState - /** - * Moderation messages (timeouts, bans, etc.) - */ @Immutable data class ModerationMessageUi( override val id: String, @@ -128,9 +109,6 @@ sealed interface ChatMessageUiState { val arguments: ImmutableList = persistentListOf(), ) : ChatMessageUiState - /** - * Channel point redemption messages - */ @Immutable data class PointRedemptionMessageUi( override val id: String, @@ -148,9 +126,6 @@ sealed interface ChatMessageUiState { val requiresUserInput: Boolean, ) : ChatMessageUiState - /** - * Date separator inserted between messages from different days - */ @Immutable data class DateSeparatorUi( override val id: String, @@ -164,9 +139,6 @@ sealed interface ChatMessageUiState { val dateText: String, ) : ChatMessageUiState - /** - * AutoMod held messages with approve/deny actions - */ @Immutable data class AutomodMessageUi( override val id: String, @@ -190,9 +162,6 @@ sealed interface ChatMessageUiState { enum class AutomodMessageStatus { Pending, Approved, Denied, Expired } } - /** - * Whisper messages - */ @Immutable data class WhisperMessageUi( override val id: String, @@ -218,9 +187,6 @@ sealed interface ChatMessageUiState { ) : ChatMessageUiState } -/** - * UI state for badges - */ @Immutable data class BadgeUi( val url: String, @@ -229,9 +195,6 @@ data class BadgeUi( val drawableResId: Int? = null, ) -/** - * UI state for emotes - */ @Immutable data class EmoteUi( val code: String, @@ -245,9 +208,6 @@ data class EmoteUi( val cheerColor: Color? = null, ) -/** - * UI state for reply threads - */ @Immutable data class ThreadUi( val rootId: String, @@ -255,9 +215,6 @@ data class ThreadUi( val message: String, ) -/** - * Converts MessageThreadHeader to ThreadUi - */ fun MessageThreadHeader.toThreadUi(): ThreadUi = ThreadUi( rootId = rootId, 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 index 61f02173f..10b5fa24a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -37,13 +37,6 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale -/** - * ViewModel for Compose-based chat display. - * - * Unlike ChatViewModel (which uses SavedStateHandle with Fragment navigation args), - * this ViewModel takes the channel directly as a constructor parameter, making it - * suitable for Compose usage where we can pass parameters via koinViewModel(). - */ @KoinViewModel class ChatViewModel( @InjectedParam private val channel: UserName, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt index ad53e9f16..3c9cf632b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt @@ -16,20 +16,6 @@ import coil3.request.ImageRequest import coil3.request.SuccessResult import com.flxrs.dankchat.utils.extensions.setRunning -/** - * Coordinates emote loading and animation synchronization across the entire chat. - * - * Based on the old ChatAdapter/EmoteRepository approach: - * - Uses LruCache to cache drawables (bounded memory, unlike ConcurrentHashMap) - * - Shares Drawable instances across all usages of the same emote - * - This keeps animated GIF frame counters synchronized naturally - * - No mutex needed - Coil handles concurrent requests internally - * - Emote animation controlled via setRunning() based on animateGifs setting - * - * Same pattern as: - * - EmoteRepository.badgeCache: LruCache(64) - * - EmoteRepository.layerCache: LruCache(256) - */ @Stable class EmoteAnimationCoordinator( val imageLoader: ImageLoader, @@ -44,12 +30,6 @@ class EmoteAnimationCoordinator( // Cache of known emote dimensions (width, height in px) to avoid layout shifts val dimensionCache = LruCache>(512) - /** - * Get or load an emote drawable. - * - * Returns cached drawable if available, otherwise loads and caches it. - * Sharing the same Drawable instance keeps animations synchronized. - */ suspend fun getOrLoadEmote( url: String, animateGifs: Boolean, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt index 7392135c3..aa7f73fc4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt @@ -14,11 +14,6 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.LayoutDirection -/** - * A [Painter] that renders a [Drawable] and implements [Drawable.Callback] to support - * animated drawables (GIF/WebP). Unlike [rememberAsyncImagePainter], this maintains - * the callback chain so animations continue after scrolling off/on screen. - */ @Stable class EmoteDrawablePainter( val drawable: Drawable, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt index e2bc58b8a..c5c7b9707 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt @@ -5,50 +5,14 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import kotlin.math.roundToInt -/** - * Emote scaling utilities that match the original ChatAdapter logic EXACTLY. - * - * Old ChatAdapter constants: - * - BASE_HEIGHT_CONSTANT = 1.173 - * - SCALE_FACTOR_CONSTANT = 1.5 / 112 - * - baseHeight = textSize * BASE_HEIGHT_CONSTANT - * - scaleFactor = baseHeight * SCALE_FACTOR_CONSTANT - * - * This ensures 100% visual parity with the old TextView-based rendering. - */ object EmoteScaling { private const val BASE_HEIGHT_CONSTANT = 1.173 private const val SCALE_FACTOR_CONSTANT = 1.5 / 112 - /** - * Calculate base emote height from font size. - * This matches the line height of text. - */ fun getBaseHeight(fontSizeSp: Float): Dp = (fontSizeSp * BASE_HEIGHT_CONSTANT).dp - /** - * Calculate scale factor from base height in pixels. - */ fun getScaleFactor(baseHeightPx: Int): Double = baseHeightPx * SCALE_FACTOR_CONSTANT - /** - * Calculate scaled emote dimensions matching old ChatAdapter.transformEmoteDrawable() EXACTLY. - * - * Old logic: - * 1. ratio = intrinsicWidth / intrinsicHeight - * 2. height = special handling for Twitch emotes, else intrinsicHeight * scale - * 3. width = height * ratio - * 4. scaledWidth = width * emote.scale - * 5. scaledHeight = height * emote.scale - * - * Returns pixel dimensions. - * - * @param intrinsicWidth Original emote width in pixels - * @param intrinsicHeight Original emote height in pixels - * @param emote The emote with scale factor and type info - * @param baseHeightPx Base height in pixels (line height) - * @return Pair of (widthPx, heightPx) in pixels - */ fun calculateEmoteDimensionsPx( intrinsicWidth: Int, intrinsicHeight: Int, @@ -59,7 +23,6 @@ object EmoteScaling { val ratio = intrinsicWidth / intrinsicHeight.toFloat() - // Match ChatAdapter height calculation exactly val height = when { intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() @@ -68,16 +31,11 @@ object EmoteScaling { } val width = (height * ratio).roundToInt() - // Apply individual emote scale val scaledWidth = (width.toFloat() * emote.scale).roundToInt() val scaledHeight = (height.toFloat() * emote.scale).roundToInt() return Pair(scaledWidth, scaledHeight) } - /** - * Calculate badge dimensions. - * Badges are always square at the base height. - */ fun getBadgeSize(fontSizeSp: Float): Dp = getBaseHeight(fontSizeSp) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt index 78c06e6ed..a2cd4809f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt @@ -23,15 +23,6 @@ import com.flxrs.dankchat.utils.extensions.forEachLayer import com.flxrs.dankchat.utils.extensions.setRunning import kotlin.math.roundToInt -/** - * Renders stacked emotes exactly like old ChatAdapter using LayerDrawable. - * - * Key differences from previous approaches: - * - Creates actual LayerDrawable like ChatAdapter did - * - Uses LruCache for LayerDrawables (not individual drawables) - * - Uses AndroidView with ImageView to render the LayerDrawable - * - NO ContentScale, NO Modifier.size on Image - drawable bounds handle everything - */ @Composable fun StackedEmote( emote: EmoteUi, @@ -152,9 +143,6 @@ fun StackedEmote( } } -/** - * Renders a single emote as a Drawable, matching old ChatAdapter behavior. - */ @Composable private fun SingleEmoteDrawable( url: String, @@ -241,10 +229,6 @@ private fun SingleEmoteDrawable( } } -/** - * Transform emote drawable exactly like old ChatAdapter.transformEmoteDrawable(). - * Phase 1: Individual scaling without maxWidth/maxHeight. - */ private fun transformEmoteDrawable( drawable: Drawable, scale: Double, @@ -271,9 +255,6 @@ private fun transformEmoteDrawable( return drawable } -/** - * Create LayerDrawable from array of drawables exactly like old ChatAdapter.toLayerDrawable(). - */ private fun Array.toLayerDrawable( scaleFactor: Double, emotes: List, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt index eb88fed29..220973a7f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt @@ -23,37 +23,12 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch -/** - * Data class to hold measured emote dimensions - */ data class EmoteDimensions( val id: String, val widthPx: Int, val heightPx: Int, ) -/** - * Renders text with inline images (badges, emotes) using SubcomposeLayout. - * - * This solves the fundamental problem with InlineTextContent: we need to know - * the size of images before creating Placeholder objects, but images load asynchronously. - * - * SubcomposeLayout allows us to: - * 1. First measure all inline images to get their actual dimensions - * 2. Create InlineTextContent with correct Placeholder sizes - * 3. Finally compose the text with properly sized placeholders - * - * This maintains natural text flow (like TextView) while supporting variable-sized - * inline content (like ImageSpans with different drawable sizes). - * - * @param text The AnnotatedString with annotations marking where inline content goes - * @param inlineContentProviders Map of content IDs to composables that will be measured - * @param modifier Modifier for the text - * @param knownDimensions Optional pre-known dimensions for inline content IDs, skipping measurement subcomposition - * @param onTextClick Callback for click events with offset position - * @param onTextLongClick Callback for long-click events with offset position - * @param interactionSource Optional interaction source for ripple effects - */ @Composable fun TextWithMeasuredInlineContent( text: AnnotatedString, 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 index f2409aaf1..f46cca6d3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -50,10 +50,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.flxrs.dankchat.ui.main.sheet.EmoteMenu import com.flxrs.dankchat.ui.main.stream.StreamViewModel -/** - * Observes PiP mode via lifecycle and configures auto-enter PiP parameters. - * Returns whether the activity is currently in PiP mode. - */ @Composable internal fun observePipMode(streamViewModel: StreamViewModel): Boolean { val context = LocalContext.current @@ -87,10 +83,6 @@ internal fun observePipMode(streamViewModel: StreamViewModel): Boolean { return isInPipMode } -/** - * Manages system bar visibility based on fullscreen state. - * Hides system bars when fullscreen, restores them when leaving. - */ @Composable internal fun FullscreenSystemBarsEffect(isFullscreen: Boolean) { val context = LocalContext.current @@ -112,11 +104,6 @@ internal fun FullscreenSystemBarsEffect(isFullscreen: Boolean) { } } -/** - * Status bar scrim that keeps text readable when content scrolls behind. - * [colorAlpha] controls the background color opacity (e.g. 0.7f for semi-transparent). - * Additional graphicsLayer transforms (e.g. fade with stream) can be applied via [modifier]. - */ @Composable internal fun StatusBarScrim( modifier: Modifier = Modifier, @@ -132,9 +119,6 @@ internal fun StatusBarScrim( ) } -/** - * Fullscreen scrim that dismisses the input overflow menu when tapped. - */ @Composable internal fun InputDismissScrim( forceOpen: Boolean, 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 index 59e12c5d3..1afbda2b8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -35,9 +35,6 @@ import com.flxrs.dankchat.ui.main.channel.ChannelTabUiState import com.flxrs.dankchat.ui.tour.TourStep import kotlinx.collections.immutable.ImmutableMap -/** - * Callbacks for chat message interactions within the pager. - */ @Stable internal class ChatPagerCallbacks( val onShowUserPopup: (UserPopupStateParams) -> Unit, @@ -52,9 +49,6 @@ internal class ChatPagerCallbacks( val scrollConnection: NestedScrollConnection? = null, ) -/** - * Scaffold content containing the channel pager, loading states, and edge gesture guards. - */ @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable internal fun MainScreenPagerContent( 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 index d3378f89a..4c69b9053 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -27,16 +27,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel -/** - * Minimal coordinator ViewModel for MainScreen. - * - * Individual components have their own ViewModels: - * - ChannelTabViewModel - Tab row state - * - ChannelPagerViewModel - Pager state - * - ChatInputViewModel - Input state - * - ChannelManagementViewModel - Channel operations - * - */ @OptIn(FlowPreview::class) @KoinViewModel class MainScreenViewModel( 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 index a3b722977..caa33410a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -53,11 +53,6 @@ import com.flxrs.dankchat.ui.main.input.TourOverlayState import com.flxrs.dankchat.utils.compose.rememberStartAlignedTooltipPositionProvider import kotlinx.collections.immutable.ImmutableList -/** - * Inline overflow menu for input actions that don't fit in the quick actions bar. - * Renders as a Surface with rounded top corners, designed to sit directly above - * the input Surface in a Column layout. - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun QuickActionsMenu( 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 index 902bd2bf6..7fff126bb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -27,10 +27,6 @@ private val TrueDarkColorScheme = onBackground = Color.White, ) -/** - * Additional color values needed for dynamic text color selection - * based on background brightness. - */ data class AdaptiveColors( val onSurfaceLight: Color, val onSurfaceDark: Color, From 224c7693993a646bbd49cc5cf2a872ca28e8f9ce Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 20:58:31 +0200 Subject: [PATCH 191/349] fix(tabs): Fix unread state never clearing because settledPage guard was always true --- .../main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 index 047b8d829..9b0ef4ad0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -381,12 +381,9 @@ fun MainScreen( } } - // Clear unread/mention indicators only on settledPage to avoid clearing - // for pages scrolled through during programmatic jumps + // Clear unread/mention indicators when page settles LaunchedEffect(composePagerState.settledPage) { - if (composePagerState.settledPage != pagerState.currentPage) { - channelPagerViewModel.clearNotifications(composePagerState.settledPage) - } + channelPagerViewModel.clearNotifications(composePagerState.settledPage) } // Pager swipe reveals toolbar From 9541d6bed9c69a9ece9fcb68b61513c44ebb1b3b Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 22:30:47 +0200 Subject: [PATCH 192/349] refactor(compose): Consolidate EmoteAnimationCoordinator, fix Chrome Custom Tabs, add stability annotations --- .../data/twitch/emote/ChatMessageEmote.kt | 2 + .../flxrs/dankchat/ui/chat/ChatComposable.kt | 14 +- .../flxrs/dankchat/ui/chat/ChatMessageText.kt | 90 -------- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 11 +- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 16 +- .../ui/chat/EmoteAnimationCoordinator.kt | 100 +-------- .../flxrs/dankchat/ui/chat/Linkification.kt | 16 +- .../ui/chat/TextWithMeasuredInlineContent.kt | 24 +- .../chat/history/MessageHistoryViewModel.kt | 8 +- .../ui/chat/mention/MentionComposable.kt | 17 +- .../ui/chat/mention/MentionViewModel.kt | 19 +- .../ui/chat/messages/AutomodMessage.kt | 12 +- .../ui/chat/messages/SystemMessages.kt | 25 +-- ...InlineContent.kt => BadgeInlineContent.kt} | 24 -- .../messages/common/MessageTextBuilders.kt | 206 ------------------ .../messages/common/MessageTextRenderer.kt | 8 +- .../messages/common/SimpleMessageContainer.kt | 20 +- .../ui/chat/messages/common/UserAnnotation.kt | 35 +++ .../ui/chat/replies/RepliesComposable.kt | 17 +- .../dankchat/ui/chat/replies/RepliesState.kt | 5 +- .../ui/chat/replies/RepliesViewModel.kt | 10 +- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 22 ++ .../ui/main/sheet/MessageHistorySheet.kt | 15 +- 23 files changed, 139 insertions(+), 577 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/{InlineContent.kt => BadgeInlineContent.kt} (75%) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/UserAnnotation.kt 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 2e531b948..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( 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 index 556a0f985..8f049295e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -5,13 +5,11 @@ 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.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.LocalPlatformContext -import coil3.imageLoader import com.flxrs.dankchat.data.UserName +import kotlinx.collections.immutable.persistentListOf import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -47,15 +45,10 @@ fun ChatComposable( parameters = { parametersOf(channel) }, ) - val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) val displaySettings by viewModel.chatDisplaySettings.collectAsStateWithLifecycle() - // Create singleton coordinator using the app's ImageLoader (with disk cache, AnimatedImageDecoder, etc.) - val context = LocalPlatformContext.current - val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) - - CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { - ChatScreen( + ChatScreen( messages = messages, fontSize = displaySettings.fontSize, callbacks = @@ -85,5 +78,4 @@ fun ChatComposable( onTourAdvance = onTourAdvance, onTourSkip = onTourSkip, ) - } // CompositionLocalProvider } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt deleted file mode 100644 index dd8e9ecff..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageText.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.flxrs.dankchat.ui.chat - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.material3.MaterialTheme -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.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.withStyle -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.em - -@Composable -fun ChatMessageText( - text: String, - fontSize: TextUnit, - modifier: Modifier = Modifier, - textColor: Color? = null, - timestamp: String? = null, - nameText: String? = null, - nameColor: Color? = null, - isAction: Boolean = false, - inlineContent: Map = emptyMap(), -) { - val timestampColor = MaterialTheme.colorScheme.onSurface - val defaultTextColor = textColor ?: MaterialTheme.colorScheme.onSurface - val defaultNameColor = nameColor ?: MaterialTheme.colorScheme.onSurface - - val annotatedString = - remember(text, timestamp, nameText, defaultNameColor, isAction, defaultTextColor, timestampColor, fontSize) { - buildAnnotatedString { - // Add timestamp if present - if (timestamp != null) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = fontSize * 0.95f, - color = timestampColor, - letterSpacing = (-0.03).em, - ), - ) { - append(timestamp) - } - append(" ") - } - - // Add username if present - if (nameText != null) { - withStyle( - SpanStyle( - color = defaultNameColor, - fontWeight = FontWeight.Bold, - ), - ) { - append(nameText) - } - if (!isAction) { - append(": ") - } else { - append(" ") - } - } - - // Add message text - withStyle( - SpanStyle( - color = if (isAction) defaultNameColor else defaultTextColor, - ), - ) { - append(text) - } - } - } - - Box(modifier = modifier) { - BasicText( - text = annotatedString, - modifier = Modifier.fillMaxWidth(), - inlineContent = inlineContent, - ) - } -} 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 index 792050c4e..20db5d1aa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -16,7 +16,6 @@ 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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -25,7 +24,6 @@ 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.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -74,11 +72,9 @@ 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.platform.LocalConfiguration 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 androidx.compose.ui.unit.dp import com.composables.core.ScrollArea import com.composables.core.Thumb @@ -99,7 +95,7 @@ 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 com.flxrs.dankchat.utils.compose.rememberStartAlignedTooltipPositionProvider +import kotlinx.collections.immutable.ImmutableList data class ChatScreenCallbacks( val onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, @@ -114,7 +110,7 @@ data class ChatScreenCallbacks( @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( - messages: List, + messages: ImmutableList, fontSize: Float, callbacks: ChatScreenCallbacks, modifier: Modifier = Modifier, @@ -787,9 +783,8 @@ private suspend fun LazyListState.scrollToCentered( val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return val viewportHeight = layoutInfo.viewportSize.height - val usableTop = topPaddingPx val usableBottom = viewportHeight - bottomPaddingPx - val usableCenter = (usableTop + usableBottom) / 2 + val usableCenter = (topPaddingPx + usableBottom) / 2 val itemCenter = itemInfo.offset + itemInfo.size / 2 val delta = (itemCenter - usableCenter).toFloat() 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 index 10b5fa24a..c1159f1e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.ui.chat import android.util.Log +import android.util.LruCache import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,9 +45,9 @@ class ChatViewModel( private val chatMessageMapper: ChatMessageMapper, private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( @@ -66,7 +67,7 @@ class ChatViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) // Mapping cache: keyed on "${message.id}-${tag}-${altBg}" to avoid re-mapping unchanged messages - private val mappingCache = HashMap(256) + private val mappingCache = LruCache(512) private var lastAppearanceSettings: AppearanceSettings? = null private var lastChatSettings: ChatSettings? = null @@ -80,7 +81,7 @@ class ChatViewModel( ) { messages, appearanceSettings, chatSettings -> // Clear cache when settings change (affects all mapped results) if (appearanceSettings != lastAppearanceSettings || chatSettings != lastChatSettings) { - mappingCache.clear() + mappingCache.evictAll() lastAppearanceSettings = appearanceSettings lastChatSettings = chatSettings } @@ -100,14 +101,13 @@ class ChatViewModel( val cacheKey = "${item.message.id}-${item.tag}-$altBg" val mapped = - mappingCache.getOrPut(cacheKey) { - chatMessageMapper.mapToUiState( + 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt index 3c9cf632b..4d9a7db69 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.ui.chat -import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.util.LruCache @@ -8,80 +7,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf -import coil3.DrawableImage -import coil3.ImageLoader -import coil3.PlatformContext -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import com.flxrs.dankchat.utils.extensions.setRunning @Stable -class EmoteAnimationCoordinator( - val imageLoader: ImageLoader, - private val platformContext: PlatformContext, -) { - // LruCache for single emote drawables (like badgeCache in EmoteRepository) - private val emoteCache = LruCache(256) +class EmoteAnimationCoordinator { + private val emoteCache = LruCache(512) + private val layerCache = LruCache(256) + val dimensionCache = LruCache>(1024) - // LruCache for stacked emote drawables (like layerCache in EmoteRepository) - private val layerCache = LruCache(128) - - // Cache of known emote dimensions (width, height in px) to avoid layout shifts - val dimensionCache = LruCache>(512) - - suspend fun getOrLoadEmote( - url: String, - animateGifs: Boolean, - ): Drawable? { - // Fast path: already cached - emoteCache.get(url)?.let { cached -> - // Control animation based on setting - if (cached is Animatable) { - cached.setRunning(animateGifs) - } - return cached - } - - // Load the emote via Coil (Coil handles concurrent requests internally) - return try { - val request = - ImageRequest - .Builder(platformContext) - .data(url) - .build() - - val result = imageLoader.execute(request) - if (result is SuccessResult) { - val image = result.image - if (image is DrawableImage) { - val drawable = image.drawable - // Cache it for reuse - emoteCache.put(url, drawable) - // Control animation - if (drawable is Animatable) { - drawable.setRunning(animateGifs) - } - drawable - } else { - null - } - } else { - null - } - } catch (_: Exception) { - null - } - } - - /** - * Check if an emote is already cached. - */ fun getCached(url: String): Drawable? = emoteCache.get(url) - /** - * Put a drawable in the cache (used by AsyncImage onSuccess callback). - */ fun putInCache( url: String, drawable: Drawable, @@ -89,14 +23,8 @@ class EmoteAnimationCoordinator( emoteCache.put(url, drawable) } - /** - * Get a cached LayerDrawable for stacked emotes. - */ fun getLayerCached(cacheKey: String): LayerDrawable? = layerCache.get(cacheKey) - /** - * Put a LayerDrawable in the cache for stacked emotes. - */ fun putLayerInCache( cacheKey: String, layerDrawable: LayerDrawable, @@ -104,9 +32,6 @@ class EmoteAnimationCoordinator( layerCache.put(cacheKey, layerDrawable) } - /** - * Clear all caches. - */ fun clear() { emoteCache.evictAll() layerCache.evictAll() @@ -114,24 +39,11 @@ class EmoteAnimationCoordinator( } } -/** - * CompositionLocal providing a shared EmoteAnimationCoordinator. - * Must be provided at the chat root (e.g., ChatComposable) so all messages - * share the same coordinator and its LruCache. - */ val LocalEmoteAnimationCoordinator = staticCompositionLocalOf { error("No EmoteAnimationCoordinator provided. Wrap your chat composables with CompositionLocalProvider.") } -/** - * Creates and remembers a singleton EmoteAnimationCoordinator using the given ImageLoader. - * Call this once at the chat root, then provide via [LocalEmoteAnimationCoordinator]. - */ @Composable -fun rememberEmoteAnimationCoordinator(imageLoader: ImageLoader): EmoteAnimationCoordinator { - val context = LocalPlatformContext.current - return remember(imageLoader) { - EmoteAnimationCoordinator(imageLoader, context) - } -} +fun rememberEmoteAnimationCoordinator(): EmoteAnimationCoordinator = + remember { EmoteAnimationCoordinator() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt index 17138fa30..01f56b28b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt @@ -3,7 +3,9 @@ package com.flxrs.dankchat.ui.chat import android.util.Patterns import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle @@ -16,20 +18,20 @@ fun AnnotatedString.Builder.appendWithLinks( ) { val matcher = Patterns.WEB_URL.matcher(text) var lastIndex = 0 + val linkStyles = + TextLinkStyles( + style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), + ) while (matcher.find()) { val start = matcher.start() var end = matcher.end() - // Skip partial matches (preceded by non-whitespace) - // Check character before match in the original text or the previousChar if at start val prevChar = if (start > 0) text[start - 1] else previousChar if (prevChar != null && !prevChar.isWhitespace()) { continue } - // Extend URL logic from ChatAdapter - // Find the actual end of the URL by continuing until whitespace or disallowed char var fixedEnd = end while (fixedEnd < text.length) { val c = text[fixedEnd] @@ -47,13 +49,12 @@ fun AnnotatedString.Builder.appendWithLinks( else -> "https://$rawUrl" } - // Append text before URL if (start > lastIndex) { append(text.substring(lastIndex, start)) } - // Append URL with annotation and style — annotation has full URL, display shows original text - pushStringAnnotation(tag = "URL", annotation = url) + val link = LinkAnnotation.Url(url = url, styles = linkStyles) + pushLink(link) withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { append(rawUrl) } @@ -62,7 +63,6 @@ fun AnnotatedString.Builder.appendWithLinks( lastIndex = end } - // Append remaining text if (lastIndex < text.length) { append(text.substring(lastIndex)) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt index 220973a7f..12a43db40 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.sp +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.launch data class EmoteDimensions( @@ -32,10 +34,10 @@ data class EmoteDimensions( @Composable fun TextWithMeasuredInlineContent( text: AnnotatedString, - inlineContentProviders: Map Unit>, + inlineContentProviders: ImmutableMap Unit>, modifier: Modifier = Modifier, style: TextStyle = TextStyle.Default, - knownDimensions: Map = emptyMap(), + knownDimensions: ImmutableMap = persistentMapOf(), onTextClick: ((Int) -> Unit)? = null, onTextLongClick: ((Int) -> Unit)? = null, interactionSource: MutableInteractionSource? = null, @@ -159,21 +161,3 @@ fun TextWithMeasuredInlineContent( } } } - -/** - * Simpler version that just wraps BasicText with measured inline content. - * Use this when you already have the dimensions or don't need click handling. - */ -@Composable -fun MeasuredInlineText( - text: AnnotatedString, - inlineContent: Map, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier) { - BasicText( - text = text, - inlineContent = inlineContent, - ) - } -} 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 index 0eda19979..afea9276c 100644 --- 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 @@ -51,9 +51,9 @@ class MessageHistoryViewModel( chatMessageRepository: ChatMessageRepository, usersRepository: UsersRepository, private val chatMessageMapper: ChatMessageMapper, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( @@ -80,7 +80,7 @@ class MessageHistoryViewModel( ).map { ChatSearchFilterParser.parse(it) } .distinctUntilChanged() - val historyUiStates: Flow> = + val historyUiStates: Flow> = combine( chatMessageRepository.getChat(channel), filters, @@ -97,7 +97,7 @@ class MessageHistoryViewModel( preferenceStore = preferenceStore, isAlternateBackground = altBg, ) - } + }.toImmutableList() }.flowOn(Dispatchers.Default) private val users: StateFlow> = 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 index e8590585e..0fff37457 100644 --- 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 @@ -2,20 +2,16 @@ package com.flxrs.dankchat.ui.chat.mention import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.LocalPlatformContext -import coil3.imageLoader import com.flxrs.dankchat.data.UserName +import kotlinx.collections.immutable.persistentListOf 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 com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator /** * Standalone composable for mentions/whispers display. @@ -42,15 +38,11 @@ fun MentionComposable( ) { val displaySettings by mentionViewModel.chatDisplaySettings.collectAsStateWithLifecycle() val messages by when { - isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) - else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) + else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) } - val context = LocalPlatformContext.current - val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) - - CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { - ChatScreen( + ChatScreen( messages = messages, fontSize = displaySettings.fontSize, callbacks = @@ -69,5 +61,4 @@ fun MentionComposable( containerColor = containerColor, onScrollToBottom = onScrollToBottom, ) - } // CompositionLocalProvider } 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 index 18668237c..1db2d71ac 100644 --- 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 @@ -29,9 +29,9 @@ import org.koin.android.annotation.KoinViewModel class MentionViewModel( chatNotificationRepository: ChatNotificationRepository, private val chatMessageMapper: ChatMessageMapper, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( @@ -61,7 +61,7 @@ class MentionViewModel( .map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), persistentListOf()) - val mentionsUiStates: Flow> = + val mentionsUiStates: Flow> = combine( mentions, appearanceSettingsDataStore.settings, @@ -75,10 +75,10 @@ class MentionViewModel( preferenceStore = preferenceStore, isAlternateBackground = altBg, ) - } + }.toImmutableList() }.flowOn(Dispatchers.Default) - val whispersUiStates: Flow> = + val whispersUiStates: Flow> = combine( whispers, appearanceSettingsDataStore.settings, @@ -92,13 +92,6 @@ class MentionViewModel( preferenceStore = preferenceStore, isAlternateBackground = altBg, ) - } + }.toImmutableList() }.flowOn(Dispatchers.Default) - - val hasMentions: StateFlow = - chatNotificationRepository.hasMentions - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) - val hasWhispers: StateFlow = - chatNotificationRepository.hasWhispers - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), false) } 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 index 0c873fb7d..0f13bb3cb 100644 --- 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 @@ -32,6 +32,8 @@ import com.flxrs.dankchat.ui.chat.TextWithMeasuredInlineContent import com.flxrs.dankchat.ui.chat.messages.common.BadgeInlineContent import com.flxrs.dankchat.ui.chat.rememberNormalizedColor import com.flxrs.dankchat.utils.resolve +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap private val AutoModBlue = Color(0xFF448AFF) @@ -204,15 +206,15 @@ fun AutomodMessageComposable( // Badge inline content providers (same pattern as PrivMessage) val badgeSize = EmoteScaling.getBadgeSize(fontSize) - val inlineContentProviders: Map Unit> = + val inlineContentProviders = remember(message.badges, fontSize) { - buildMap { + buildMap Unit> { message.badges.forEach { badge -> put("BADGE_${badge.position}") { BadgeInlineContent(badge = badge, size = badgeSize) } } - } + }.toImmutableMap() } val density = LocalDensity.current @@ -223,7 +225,7 @@ fun AutomodMessageComposable( message.badges.forEach { badge -> put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) } - } + }.toImmutableMap() } val resolvedAlpha = @@ -270,7 +272,7 @@ fun AutomodMessageComposable( bodyString?.let { TextWithMeasuredInlineContent( text = it, - inlineContentProviders = emptyMap(), + inlineContentProviders = persistentMapOf(), style = TextStyle(fontSize = textSize), modifier = Modifier.fillMaxWidth(), ) 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 index a97077431..f1488fc20 100644 --- 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 @@ -5,7 +5,7 @@ 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.foundation.text.ClickableText +import androidx.compose.material3.Text import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -93,7 +93,6 @@ fun UserNoticeMessageComposable( val timestampColor = MaterialTheme.colorScheme.onSurface val nameColor = rememberNormalizedColor(message.rawNameColor, bgColor) val textSize = fontSize.sp - val context = LocalContext.current val annotatedString = remember(message, textColor, nameColor, linkColor, timestampColor, textSize) { @@ -157,18 +156,10 @@ fun UserNoticeMessageComposable( .background(bgColor, highlightShape) .padding(horizontal = 2.dp, vertical = 2.dp), ) { - ClickableText( + Text( text = annotatedString, style = TextStyle(fontSize = textSize), modifier = Modifier.fillMaxWidth(), - onClick = { offset -> - annotatedString - .getStringAnnotations("URL", offset, offset) - .firstOrNull() - ?.let { annotation -> - launchCustomTab(context, annotation.item) - } - }, ) } } @@ -218,7 +209,7 @@ fun ModerationMessageComposable( val targetColor = rememberNormalizedColor(message.targetColor, bgColor) val textSize = fontSize.sp val resolvedMessage = message.message.resolve() - val context = LocalContext.current + val linkColor = rememberAdaptiveLinkColor(bgColor) val dimmedTextColor = textColor.copy(alpha = 0.7f) @@ -322,18 +313,10 @@ fun ModerationMessageComposable( .background(bgColor) .padding(horizontal = 2.dp, vertical = 2.dp), ) { - ClickableText( + Text( text = annotatedString, style = TextStyle(fontSize = textSize), modifier = Modifier.fillMaxWidth(), - onClick = { offset -> - annotatedString - .getStringAnnotations("URL", offset, offset) - .firstOrNull() - ?.let { annotation -> - launchCustomTab(context, annotation.item) - } - }, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BadgeInlineContent.kt similarity index 75% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BadgeInlineContent.kt index 24d4af925..85280c2ea 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/InlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BadgeInlineContent.kt @@ -13,9 +13,6 @@ 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 -import com.flxrs.dankchat.ui.chat.EmoteAnimationCoordinator -import com.flxrs.dankchat.ui.chat.EmoteUi -import com.flxrs.dankchat.ui.chat.StackedEmote private val FfzModGreen = Color(0xFF34AE0A) @@ -66,24 +63,3 @@ fun BadgeInlineContent( } } -/** - * Renders an emote (potentially stacked) as inline content in a message. - */ -@Composable -fun EmoteInlineContent( - emote: EmoteUi, - fontSize: Float, - coordinator: EmoteAnimationCoordinator, - animateGifs: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - StackedEmote( - emote = emote, - fontSize = fontSize, - emoteCoordinator = coordinator, - animateGifs = animateGifs, - modifier = modifier, - onClick = onClick, - ) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt deleted file mode 100644 index 73eac368e..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextBuilders.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.flxrs.dankchat.ui.chat.messages.common - -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -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.TextUnit -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.ui.chat.BadgeUi -import com.flxrs.dankchat.ui.chat.EmoteAnimationCoordinator -import com.flxrs.dankchat.ui.chat.EmoteScaling -import com.flxrs.dankchat.ui.chat.EmoteUi - -/** - * Appends a formatted timestamp to the AnnotatedString builder. - */ -fun AnnotatedString.Builder.appendTimestamp( - timestamp: String, - fontSize: TextUnit, - color: Color, -) { - if (timestamp.isNotEmpty()) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = (fontSize.value * 0.95f).sp, - color = color, - letterSpacing = (-0.03).em, - ), - ) { - append(timestamp) - append(" ") - } - } -} - -/** - * Appends badge inline content markers to the AnnotatedString builder. - */ -fun AnnotatedString.Builder.appendBadges(badges: List) { - badges.forEach { badge -> - appendInlineContent("BADGE_${badge.position}", "[badge]") - append(" ") - } -} - -/** - * Appends message text with emotes, handling emote inline content and spacing. - */ -fun AnnotatedString.Builder.appendMessageWithEmotes( - message: String, - emotes: List, - textColor: Color, -) { - withStyle(SpanStyle(color = textColor)) { - var currentPos = 0 - emotes.sortedBy { it.position.first }.forEach { emote -> - // Text before emote - if (currentPos < emote.position.first) { - append(message.substring(currentPos, emote.position.first)) - } - - // 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.length && !message[nextPos].isWhitespace()) { - append(" ") - } - - currentPos = emote.position.last + 1 - } - - // Remaining text - if (currentPos < message.length) { - append(message.substring(currentPos)) - } - } -} - -/** - * Appends a clickable username with annotation for click handling. - */ -fun AnnotatedString.Builder.appendClickableUsername( - displayText: String, - userId: UserId?, - userName: UserName, - displayName: DisplayName, - channel: String = "", - color: Color, -) { - if (displayText.isNotEmpty()) { - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - color = color, - ), - ) { - val annotation = - if (channel.isNotEmpty()) { - "${userId?.value ?: ""}|${userName.value}|${displayName.value}|$channel" - } else { - "${userId?.value ?: ""}|${userName.value}|${displayName.value}" - } - pushStringAnnotation( - tag = "USER", - annotation = annotation, - ) - append(displayText) - pop() - } - } -} - -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 - } - } -} - -/** - * Builds inline content providers for badges and emotes. - */ -@Composable -fun buildInlineContentProviders( - badges: List, - emotes: List, - fontSize: Float, - coordinator: EmoteAnimationCoordinator, - animateGifs: Boolean, - onEmoteClick: (List) -> Unit, -): Map Unit> { - val badgeSize = EmoteScaling.getBadgeSize(fontSize) - - return buildMap { - // Badge providers - badges.forEach { badge -> - put("BADGE_${badge.position}") { - BadgeInlineContent(badge = badge, size = badgeSize) - } - } - - // Emote providers - emotes.forEach { emote -> - put("EMOTE_${emote.code}") { - EmoteInlineContent( - emote = emote, - fontSize = fontSize, - coordinator = coordinator, - animateGifs = animateGifs, - onClick = { onEmoteClick(emotes) }, - ) - } - } - } -} 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 index eb10d5c0d..349f5f133 100644 --- 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 @@ -27,6 +27,8 @@ import com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.StackedEmote import com.flxrs.dankchat.ui.chat.TextWithMeasuredInlineContent import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableMap @Composable fun MessageTextWithInlineContent( @@ -45,7 +47,7 @@ fun MessageTextWithInlineContent( val density = LocalDensity.current val badgeSize = EmoteScaling.getBadgeSize(fontSize) - val inlineContentProviders: Map Unit> = + val inlineContentProviders: ImmutableMap Unit> = remember(badges, emotes, fontSize) { buildMap Unit> { badges.forEach { badge -> @@ -66,7 +68,7 @@ fun MessageTextWithInlineContent( ) } } - } + }.toImmutableMap() } val knownDimensions = @@ -98,7 +100,7 @@ fun MessageTextWithInlineContent( } } } - } + }.toImmutableMap() } TextWithMeasuredInlineContent( 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 index 75fdd1c5f..eee103064 100644 --- 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 @@ -5,14 +5,13 @@ 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.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text 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.LocalContext import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -24,12 +23,6 @@ import com.flxrs.dankchat.ui.chat.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.rememberBackgroundColor -/** - * A simple message container for system messages, notices, and other simple message types. - * Handles background color, padding, and text rendering consistently. - * Supports clickable URLs in the message text. - */ -@Suppress("DEPRECATION") @Composable fun SimpleMessageContainer( message: String, @@ -44,7 +37,6 @@ fun SimpleMessageContainer( val textColor = rememberAdaptiveTextColor(bgColor) val linkColor = rememberAdaptiveLinkColor(bgColor) val timestampColor = MaterialTheme.colorScheme.onSurface - val context = LocalContext.current val annotatedString = remember(message, timestamp, textColor, linkColor, timestampColor, fontSize) { @@ -68,18 +60,10 @@ fun SimpleMessageContainer( .background(bgColor) .padding(horizontal = 2.dp, vertical = 2.dp), ) { - ClickableText( + Text( text = annotatedString, style = TextStyle(fontSize = fontSize), modifier = Modifier.fillMaxWidth(), - onClick = { offset -> - annotatedString - .getStringAnnotations("URL", offset, offset) - .firstOrNull() - ?.let { annotation -> - launchCustomTab(context, annotation.item) - } - }, ) } } 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 index bcdfd6913..0166e58bd 100644 --- 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 @@ -2,19 +2,15 @@ package com.flxrs.dankchat.ui.chat.replies import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider 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 coil3.compose.LocalPlatformContext -import coil3.imageLoader 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.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator +import kotlinx.collections.immutable.persistentListOf /** * Standalone composable for reply thread display. @@ -39,13 +35,9 @@ fun RepliesComposable( onScrollToBottom: () -> Unit = {}, ) { val displaySettings by repliesViewModel.chatDisplaySettings.collectAsStateWithLifecycle() - val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(emptyList())) + val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(persistentListOf())) - val context = LocalPlatformContext.current - val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) - - CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { - when (uiState) { + when (uiState) { is RepliesUiState.Found -> { ChatScreen( messages = (uiState as RepliesUiState.Found).items, @@ -70,6 +62,5 @@ fun RepliesComposable( onMissing() } } - } - } // CompositionLocalProvider + } } 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 index f9c5db9d7..842ac23c8 100644 --- 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 @@ -3,13 +3,14 @@ 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: List, + val items: ImmutableList, ) : RepliesState } @@ -18,6 +19,6 @@ sealed interface RepliesUiState { data object NotFound : RepliesUiState data class Found( - val items: List, + 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 index 297afd3cf..a718e210f 100644 --- 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 @@ -9,6 +9,8 @@ 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.utils.extensions.isEven +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -46,9 +48,9 @@ class RepliesViewModel( .map { when { it.isEmpty() -> RepliesState.NotFound - else -> RepliesState.Found(it) + else -> RepliesState.Found(it.toImmutableList()) } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(emptyList())) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(persistentListOf())) val uiState: StateFlow = combine( @@ -71,10 +73,10 @@ class RepliesViewModel( preferenceStore = preferenceStore, isAlternateBackground = altBg, ) - } + }.toImmutableList() RepliesUiState.Found(uiMessages) } } }.flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(emptyList())) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(persistentListOf())) } 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 index 9b0ef4ad0..00f273fe5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -29,6 +29,7 @@ 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 @@ -45,9 +46,12 @@ 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 @@ -59,7 +63,10 @@ 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.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab +import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.mention.MentionViewModel import com.flxrs.dankchat.ui.chat.swipeDownToHide import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel @@ -393,6 +400,20 @@ fun MainScreen( } } + 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, + ) { Box( modifier = Modifier @@ -1036,4 +1057,5 @@ fun MainScreen( } } } + } } 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 index 233239b99..a3e147eee 100644 --- 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 @@ -38,7 +38,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults 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 @@ -60,19 +59,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.LocalPlatformContext -import coil3.imageLoader 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.ChatScreen import com.flxrs.dankchat.ui.chat.ChatScreenCallbacks -import com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker import com.flxrs.dankchat.ui.chat.history.MessageHistoryViewModel -import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.ui.main.input.SuggestionDropdown +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException @@ -91,7 +87,7 @@ fun MessageHistorySheet( } val displaySettings by viewModel.chatDisplaySettings.collectAsStateWithLifecycle() - val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = emptyList()) + val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) val filterSuggestions by viewModel.filterSuggestions.collectAsStateWithLifecycle() val sheetBackgroundColor = @@ -138,9 +134,6 @@ fun MessageHistorySheet( } val scrollModifier = Modifier.nestedScroll(scrollTracker) - val context = LocalPlatformContext.current - val emoteCoordinator = rememberEmoteAnimationCoordinator(context.imageLoader) - Box( modifier = Modifier @@ -154,8 +147,7 @@ fun MessageHistorySheet( translationY = backProgress * 100f }, ) { - CompositionLocalProvider(LocalEmoteAnimationCoordinator provides emoteCoordinator) { - ChatScreen( + ChatScreen( messages = messages, fontSize = displaySettings.fontSize, callbacks = @@ -172,7 +164,6 @@ fun MessageHistorySheet( containerColor = sheetBackgroundColor, onScrollToBottom = { toolbarVisible = true }, ) - } AnimatedVisibility( visible = toolbarVisible, From b4de6a5d93d3bf6fa64001222aeb1176b71f4399 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 22:31:12 +0200 Subject: [PATCH 193/349] cleanup: Remove dead code, unused imports, and minor code review fixes --- .../dankchat/ui/chat/ChatMessageMapper.kt | 14 +++++------ .../dankchat/ui/chat/ChatScrollBehavior.kt | 5 ++-- .../flxrs/dankchat/ui/chat/EmoteScaling.kt | 24 ------------------- .../flxrs/dankchat/ui/chat/StackedEmote.kt | 1 - .../chat/message/MessageOptionsViewModel.kt | 6 ++--- .../dankchat/ui/chat/messages/PrivMessage.kt | 1 - 6 files changed, 13 insertions(+), 38 deletions(-) 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 index 82bebbacd..4195c7039 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -372,7 +372,7 @@ class ChatMessageMapper( } val arguments = - buildList { + buildList { duration?.let(::add) reason?.takeIf { it.isNotBlank() }?.let(::add) sourceBroadcasterDisplay?.toString()?.let(::add) @@ -807,12 +807,12 @@ class ChatMessageMapper( isDark: Boolean, ): Int = when (type) { - HighlightType.Subscription, HighlightType.Announcement -> if (isDark) 0xFF6A45A0.toInt() else 0xFF7E57C2.toInt() - HighlightType.Username, HighlightType.Custom, HighlightType.Reply, HighlightType.Notification, HighlightType.Badge -> if (isDark) 0xFF8C3A3B.toInt() else 0xFFCF5050.toInt() - HighlightType.ChannelPointRedemption -> if (isDark) 0xFF00606B.toInt() else 0xFF458B93.toInt() - HighlightType.FirstMessage -> if (isDark) 0xFF3A6600.toInt() else 0xFF558B2F.toInt() - HighlightType.ElevatedMessage -> if (isDark) 0xFF6B5800.toInt() else 0xFFB08D2A.toInt() - } + HighlightType.Subscription, HighlightType.Announcement -> if (isDark) 0xFF6A45A0 else 0xFF7E57C2 + 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( 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 index ace92b4c5..c8c671cdc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.input.pointer.positionChange /** * Observes scroll direction and fires [onHide]/[onShow] when the accumulated - * scroll delta exceeds [thresholdPx]. + * scroll delta exceeds [hideThresholdPx]/[showThresholdPx]. * * With `reverseLayout = true` the nested scroll deltas are inverted: * `available.y > 0` = finger up = reading old messages = hide toolbar; @@ -28,6 +28,7 @@ class ScrollDirectionTracker( ) : NestedScrollConnection { private var accumulated = 0f + @Suppress("SameReturnValue") override fun onPostScroll( consumed: Offset, available: Offset, @@ -68,7 +69,7 @@ fun Modifier.swipeDownToHide( onHide: () -> Unit, ): Modifier { if (!enabled) return this - return this.pointerInput(enabled) { + return this.pointerInput(true) { awaitEachGesture { awaitFirstDown(pass = PointerEventPass.Initial, requireUnconsumed = false) var totalDragY = 0f diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt index c5c7b9707..f64034220 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt @@ -13,29 +13,5 @@ object EmoteScaling { fun getScaleFactor(baseHeightPx: Int): Double = baseHeightPx * SCALE_FACTOR_CONSTANT - fun calculateEmoteDimensionsPx( - intrinsicWidth: Int, - intrinsicHeight: Int, - emote: ChatMessageEmote, - baseHeightPx: Int, - ): Pair { - val scale = baseHeightPx * SCALE_FACTOR_CONSTANT - - 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.toFloat() * emote.scale).roundToInt() - val scaledHeight = (height.toFloat() * emote.scale).roundToInt() - - return Pair(scaledWidth, scaledHeight) - } - fun getBadgeSize(fontSizeSp: Float): Dp = getBaseHeight(fontSizeSp) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt index a2cd4809f..cdfbea2a9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt @@ -94,7 +94,6 @@ fun StackedEmote( }.toTypedArray() if (drawables.isNotEmpty()) { - // Create LayerDrawable exactly like old implementation val layerDrawable = drawables.toLayerDrawable(scaleFactor, emote.emotes) emoteCoordinator.putLayerInCache(cacheKey, layerDrawable) // Store dimensions for future placeholder sizing 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 index c56865899..da0d40a26 100644 --- 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 @@ -31,13 +31,13 @@ class MessageOptionsViewModel( @InjectedParam private val canModerateParam: Boolean, @InjectedParam private val canReplyParam: Boolean, private val chatRepository: ChatRepository, - private val chatMessageRepository: ChatMessageRepository, - private val chatConnector: ChatConnector, - private val chatNotificationRepository: ChatNotificationRepository, 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) 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 index 0da1a836b..af9fcb561 100644 --- 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 @@ -15,7 +15,6 @@ 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.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable From a4371ae0d2978d39408cbb9ac622fb817474eea6 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 30 Mar 2026 22:41:13 +0200 Subject: [PATCH 194/349] refactor(chat): Restructure ui/chat file organization and dissolve EmoteScaling --- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 60 +- .../flxrs/dankchat/ui/chat/EmoteScaling.kt | 17 - .../{ => emote}/EmoteAnimationCoordinator.kt | 5 +- .../chat/{ => emote}/EmoteDrawablePainter.kt | 2 +- .../ui/chat/{ => emote}/StackedEmote.kt | 16 +- .../ui/chat/mention/MentionComposable.kt | 38 +- .../ui/chat/mention/MentionViewModel.kt | 38 +- .../ui/chat/messages/AutomodMessage.kt | 10 +- .../dankchat/ui/chat/messages/PrivMessage.kt | 10 +- .../ui/chat/messages/SystemMessages.kt | 12 +- .../ui/chat/messages/WhisperAndRedemption.kt | 10 +- .../common}/AdaptiveTextColor.kt | 2 +- .../{ => messages/common}/BackgroundColor.kt | 2 +- .../messages/common/BadgeInlineContent.kt | 1 - .../{ => messages/common}/Linkification.kt | 2 +- .../messages/common/MessageTextRenderer.kt | 12 +- .../messages/common/SimpleMessageContainer.kt | 4 - .../common}/TextWithMeasuredInlineContent.kt | 2 +- .../ui/chat/replies/RepliesComposable.kt | 44 +- .../ui/chat/replies/RepliesViewModel.kt | 19 +- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 1149 +++++++++-------- .../ui/main/sheet/MessageHistorySheet.kt | 32 +- 22 files changed, 738 insertions(+), 749 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/{ => emote}/EmoteAnimationCoordinator.kt (94%) rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/{ => emote}/EmoteDrawablePainter.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/{ => emote}/StackedEmote.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/{ => messages/common}/AdaptiveTextColor.kt (98%) rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/{ => messages/common}/BackgroundColor.kt (95%) rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/{ => messages/common}/Linkification.kt (97%) rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/{ => messages/common}/TextWithMeasuredInlineContent.kt (99%) 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 index 8f049295e..1baf14853 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -9,8 +9,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.data.UserName -import kotlinx.collections.immutable.persistentListOf 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 @@ -49,33 +49,33 @@ fun ChatComposable( 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) }, - ), - showLineSeparator = displaySettings.showLineSeparator, - 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, - ) + 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) }, + ), + showLineSeparator = displaySettings.showLineSeparator, + 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/EmoteScaling.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt deleted file mode 100644 index f64034220..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteScaling.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flxrs.dankchat.ui.chat - -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import kotlin.math.roundToInt - -object EmoteScaling { - private const val BASE_HEIGHT_CONSTANT = 1.173 - private const val SCALE_FACTOR_CONSTANT = 1.5 / 112 - - fun getBaseHeight(fontSizeSp: Float): Dp = (fontSizeSp * BASE_HEIGHT_CONSTANT).dp - - fun getScaleFactor(baseHeightPx: Int): Double = baseHeightPx * SCALE_FACTOR_CONSTANT - - fun getBadgeSize(fontSizeSp: Float): Dp = getBaseHeight(fontSizeSp) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteAnimationCoordinator.kt similarity index 94% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteAnimationCoordinator.kt index 4d9a7db69..a270bbebb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteAnimationCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteAnimationCoordinator.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.chat +package com.flxrs.dankchat.ui.chat.emote import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable @@ -45,5 +45,4 @@ val LocalEmoteAnimationCoordinator = } @Composable -fun rememberEmoteAnimationCoordinator(): EmoteAnimationCoordinator = - remember { EmoteAnimationCoordinator() } +fun rememberEmoteAnimationCoordinator(): EmoteAnimationCoordinator = remember { EmoteAnimationCoordinator() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteDrawablePainter.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteDrawablePainter.kt index aa7f73fc4..f88b09786 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/EmoteDrawablePainter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteDrawablePainter.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.chat +package com.flxrs.dankchat.ui.chat.emote import android.graphics.drawable.Drawable import android.os.Handler diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/StackedEmote.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/StackedEmote.kt index cdfbea2a9..c0d80a797 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/StackedEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/StackedEmote.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.chat +package com.flxrs.dankchat.ui.chat.emote import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable @@ -13,16 +13,26 @@ 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, @@ -35,9 +45,9 @@ fun StackedEmote( ) { val context = LocalPlatformContext.current val density = LocalDensity.current - val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeight = emoteBaseHeight(fontSize) val baseHeightPx = with(density) { baseHeight.toPx().toInt() } - val scaleFactor = EmoteScaling.getScaleFactor(baseHeightPx) + val scaleFactor = emoteScaleFactor(baseHeightPx) // For single emote, render directly without LayerDrawable if (emote.urls.size == 1 && emote.emotes.isNotEmpty()) { 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 index 0fff37457..e1469ec2a 100644 --- 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 @@ -7,11 +7,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.data.UserName -import kotlinx.collections.immutable.persistentListOf 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. @@ -43,22 +43,22 @@ fun MentionComposable( } ChatScreen( - messages = messages, - fontSize = displaySettings.fontSize, - callbacks = - ChatScreenCallbacks( - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - onWhisperReply = if (isWhisperTab) onWhisperReply else null, - ), - showLineSeparator = displaySettings.showLineSeparator, - animateGifs = displaySettings.animateGifs, - showChannelPrefix = !isWhisperTab, - modifier = modifier, - contentPadding = contentPadding, - scrollModifier = scrollModifier, - containerColor = containerColor, - onScrollToBottom = onScrollToBottom, - ) + messages = messages, + fontSize = displaySettings.fontSize, + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onWhisperReply = if (isWhisperTab) onWhisperReply else null, + ), + showLineSeparator = displaySettings.showLineSeparator, + 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 index 1db2d71ac..8d37efcd7 100644 --- 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 @@ -67,15 +67,16 @@ class MentionViewModel( appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, ) { messages, appearanceSettings, chatSettings -> - messages.mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) - }.toImmutableList() + messages + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.toImmutableList() }.flowOn(Dispatchers.Default) val whispersUiStates: Flow> = @@ -84,14 +85,15 @@ class MentionViewModel( appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, ) { messages, appearanceSettings, chatSettings -> - messages.mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) - }.toImmutableList() + messages + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.toImmutableList() }.flowOn(Dispatchers.Default) } 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 index 0f13bb3cb..486249c73 100644 --- 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 @@ -26,11 +26,11 @@ 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.EmoteDimensions -import com.flxrs.dankchat.ui.chat.EmoteScaling -import com.flxrs.dankchat.ui.chat.TextWithMeasuredInlineContent +import com.flxrs.dankchat.ui.chat.emote.emoteBaseHeight import com.flxrs.dankchat.ui.chat.messages.common.BadgeInlineContent -import com.flxrs.dankchat.ui.chat.rememberNormalizedColor +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.rememberNormalizedColor import com.flxrs.dankchat.utils.resolve import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableMap @@ -205,7 +205,7 @@ fun AutomodMessageComposable( } // Badge inline content providers (same pattern as PrivMessage) - val badgeSize = EmoteScaling.getBadgeSize(fontSize) + val badgeSize = emoteBaseHeight(fontSize) val inlineContentProviders = remember(message.badges, fontSize) { buildMap Unit> { 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 index af9fcb561..ca249aa7e 100644 --- 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 @@ -38,15 +38,15 @@ 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.appendWithLinks import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent +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.ui.chat.rememberAdaptiveLinkColor -import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor -import com.flxrs.dankchat.ui.chat.rememberBackgroundColor -import com.flxrs.dankchat.ui.chat.rememberNormalizedColor import com.flxrs.dankchat.utils.resolve /** 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 index f1488fc20..811f14b1e 100644 --- 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 @@ -5,8 +5,8 @@ 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.Text import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember @@ -24,14 +24,14 @@ 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.appendWithLinks import com.flxrs.dankchat.ui.chat.messages.common.SimpleMessageContainer +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.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.ui.chat.rememberAdaptiveLinkColor -import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor -import com.flxrs.dankchat.ui.chat.rememberBackgroundColor -import com.flxrs.dankchat.ui.chat.rememberNormalizedColor import com.flxrs.dankchat.utils.TextResource import com.flxrs.dankchat.utils.resolve 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 index 7d3210ec2..6077ffb1c 100644 --- 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 @@ -38,15 +38,15 @@ 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.appendWithLinks import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent +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.ui.chat.rememberAdaptiveLinkColor -import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor -import com.flxrs.dankchat.ui.chat.rememberBackgroundColor -import com.flxrs.dankchat.ui.chat.rememberNormalizedColor /** * Renders a whisper message (private message between users) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/AdaptiveTextColor.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/AdaptiveTextColor.kt index ed28a09a6..eb066744a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/AdaptiveTextColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/AdaptiveTextColor.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.chat +package com.flxrs.dankchat.ui.chat.messages.common import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BackgroundColor.kt similarity index 95% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BackgroundColor.kt index ef06524de..402e7fbc6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/BackgroundColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BackgroundColor.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.chat +package com.flxrs.dankchat.ui.chat.messages.common import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme 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 index 85280c2ea..21eb64072 100644 --- 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 @@ -62,4 +62,3 @@ fun BadgeInlineContent( } } } - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/Linkification.kt similarity index 97% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/Linkification.kt index 01f56b28b..9106083cc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/Linkification.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/Linkification.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.chat +package com.flxrs.dankchat.ui.chat.messages.common import android.util.Patterns import androidx.compose.ui.graphics.Color 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 index 349f5f133..18ad6a030 100644 --- 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 @@ -20,12 +20,10 @@ 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.EmoteDimensions -import com.flxrs.dankchat.ui.chat.EmoteScaling import com.flxrs.dankchat.ui.chat.EmoteUi -import com.flxrs.dankchat.ui.chat.LocalEmoteAnimationCoordinator -import com.flxrs.dankchat.ui.chat.StackedEmote -import com.flxrs.dankchat.ui.chat.TextWithMeasuredInlineContent +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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableMap @@ -46,7 +44,7 @@ fun MessageTextWithInlineContent( val emoteCoordinator = LocalEmoteAnimationCoordinator.current val density = LocalDensity.current - val badgeSize = EmoteScaling.getBadgeSize(fontSize) + val badgeSize = emoteBaseHeight(fontSize) val inlineContentProviders: ImmutableMap Unit> = remember(badges, emotes, fontSize) { buildMap Unit> { @@ -79,7 +77,7 @@ fun MessageTextWithInlineContent( put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) } - val baseHeight = EmoteScaling.getBaseHeight(fontSize) + val baseHeight = emoteBaseHeight(fontSize) val baseHeightPx = with(density) { baseHeight.toPx().toInt() } emotes.forEach { emote -> val id = "EMOTE_${emote.code}" 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 index eee103064..4e92f8e7c 100644 --- 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 @@ -18,10 +18,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.ui.chat.appendWithLinks -import com.flxrs.dankchat.ui.chat.rememberAdaptiveLinkColor -import com.flxrs.dankchat.ui.chat.rememberAdaptiveTextColor -import com.flxrs.dankchat.ui.chat.rememberBackgroundColor @Composable fun SimpleMessageContainer( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/TextWithMeasuredInlineContent.kt similarity index 99% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/TextWithMeasuredInlineContent.kt index 12a43db40..9e2151936 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/TextWithMeasuredInlineContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/TextWithMeasuredInlineContent.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.chat +package com.flxrs.dankchat.ui.chat.messages.common import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource 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 index 0166e58bd..71216c02d 100644 --- 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 @@ -38,29 +38,29 @@ fun RepliesComposable( 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, - ), - showLineSeparator = displaySettings.showLineSeparator, - animateGifs = displaySettings.animateGifs, - modifier = modifier, - contentPadding = contentPadding, - scrollModifier = scrollModifier, - containerColor = containerColor, - onScrollToBottom = onScrollToBottom, - ) - } + is RepliesUiState.Found -> { + ChatScreen( + messages = (uiState as RepliesUiState.Found).items, + fontSize = displaySettings.fontSize, + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + ), + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, + modifier = modifier, + contentPadding = contentPadding, + scrollModifier = scrollModifier, + containerColor = containerColor, + onScrollToBottom = onScrollToBottom, + ) + } - is RepliesUiState.NotFound -> { - LaunchedEffect(Unit) { - onMissing() - } + is RepliesUiState.NotFound -> { + LaunchedEffect(Unit) { + onMissing() } + } } } 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 index a718e210f..5699b6f7f 100644 --- 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 @@ -65,15 +65,16 @@ class RepliesViewModel( is RepliesState.Found -> { val uiMessages = - repliesState.items.mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) - }.toImmutableList() + repliesState.items + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + chatMessageMapper.mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.toImmutableList() RepliesUiState.Found(uiMessages) } } 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 index 00f273fe5..6d9dd0e67 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -63,11 +63,11 @@ 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.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker -import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab -import com.flxrs.dankchat.ui.chat.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.emote.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.emote.rememberEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.mention.MentionViewModel +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab import com.flxrs.dankchat.ui.chat.swipeDownToHide import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel import com.flxrs.dankchat.ui.main.channel.ChannelPagerViewModel @@ -402,660 +402,661 @@ fun MainScreen( val emoteCoordinator = rememberEmoteAnimationCoordinator() val customTabContext = LocalContext.current - val customTabUriHandler = remember(customTabContext) { - object : UriHandler { - override fun openUri(uri: String) { - launchCustomTab(customTabContext, uri) + val customTabUriHandler = + remember(customTabContext) { + object : UriHandler { + override fun openUri(uri: String) { + launchCustomTab(customTabContext, uri) + } } } - } CompositionLocalProvider( LocalEmoteAnimationCoordinator provides emoteCoordinator, LocalUriHandler provides customTabUriHandler, ) { - Box( - modifier = - Modifier - .fillMaxSize() - .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 = effectiveShowInput && !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, - onToggleStream = { - when { - currentStream != null -> streamViewModel.closeStream() - else -> activeChannel?.let { streamViewModel.toggleStream(it) } - } - }, - onModActions = dialogViewModel::showModActions, - onInputActionsChange = mainScreenViewModel::updateInputActions, - onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, - onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, - onNewWhisper = - if (inputState.isWhisperTabActive) { - dialogViewModel::showNewWhisper - } else { - null + Box( + modifier = + Modifier + .fillMaxSize() + .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 = effectiveShowInput && !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() + } }, - onRepeatedSendChange = chatInputViewModel::setRepeatedSend, - ), - isUploading = dialogState.isUploading, - isLoading = tabState.loading, - isFullscreen = isFullscreen, - isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), - isStreamActive = currentStream != null, - hasStreamData = hasStreamData, - isSheetOpen = isSheetOpen, - inputActions = - when (fullScreenSheetState) { - is FullScreenSheetState.Replies -> { - persistentListOf(InputAction.LastMessage) - } + 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, + onToggleStream = { + when { + currentStream != null -> streamViewModel.closeStream() + else -> activeChannel?.let { streamViewModel.toggleStream(it) } + } + }, + onModActions = dialogViewModel::showModActions, + onInputActionsChange = mainScreenViewModel::updateInputActions, + onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, + onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, + onNewWhisper = + if (inputState.isWhisperTabActive) { + dialogViewModel::showNewWhisper + } else { + null + }, + onRepeatedSendChange = chatInputViewModel::setRepeatedSend, + ), + isUploading = dialogState.isUploading, + isLoading = tabState.loading, + isFullscreen = isFullscreen, + isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), + isStreamActive = currentStream != null, + 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.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, - ) - }, - ) - } + 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) } - } + // 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.LongClickTab -> { + dialogViewModel.showManageChannels() + } - ToolbarAction.AddChannel -> { - featureTourViewModel.onAddedChannelFromToolbar() - dialogViewModel.showAddChannel() - } + ToolbarAction.AddChannel -> { + featureTourViewModel.onAddedChannelFromToolbar() + dialogViewModel.showAddChannel() + } - ToolbarAction.OpenMentions -> { - sheetNavigationViewModel.openMentions() - channelTabViewModel.clearAllMentionCounts() - } + ToolbarAction.OpenMentions -> { + sheetNavigationViewModel.openMentions() + channelTabViewModel.clearAllMentionCounts() + } - ToolbarAction.Login -> { - onLogin() - } + ToolbarAction.Login -> { + onLogin() + } - ToolbarAction.Relogin -> { - onRelogin() - } + ToolbarAction.Relogin -> { + onRelogin() + } - ToolbarAction.Logout -> { - dialogViewModel.showLogout() - } + ToolbarAction.Logout -> { + dialogViewModel.showLogout() + } - ToolbarAction.ManageChannels -> { - dialogViewModel.showManageChannels() - } + ToolbarAction.ManageChannels -> { + dialogViewModel.showManageChannels() + } - ToolbarAction.OpenChannel -> { - onOpenChannel() - } + ToolbarAction.OpenChannel -> { + onOpenChannel() + } - ToolbarAction.RemoveChannel -> { - dialogViewModel.showRemoveChannel() - } + ToolbarAction.RemoveChannel -> { + dialogViewModel.showRemoveChannel() + } - ToolbarAction.ReportChannel -> { - onReportChannel() - } + ToolbarAction.ReportChannel -> { + onReportChannel() + } - ToolbarAction.BlockChannel -> { - dialogViewModel.showBlockChannel() - } + ToolbarAction.BlockChannel -> { + dialogViewModel.showBlockChannel() + } - ToolbarAction.CaptureImage -> { - if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else dialogViewModel.setPendingUploadAction(onCaptureImage) - } + ToolbarAction.CaptureImage -> { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else dialogViewModel.setPendingUploadAction(onCaptureImage) + } - ToolbarAction.CaptureVideo -> { - if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else dialogViewModel.setPendingUploadAction(onCaptureVideo) - } + ToolbarAction.CaptureVideo -> { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else dialogViewModel.setPendingUploadAction(onCaptureVideo) + } - ToolbarAction.ChooseMedia -> { - if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else dialogViewModel.setPendingUploadAction(onChooseMedia) - } + ToolbarAction.ChooseMedia -> { + if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else dialogViewModel.setPendingUploadAction(onChooseMedia) + } - ToolbarAction.ReloadEmotes -> { - activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } - } + ToolbarAction.ReloadEmotes -> { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + } - ToolbarAction.Reconnect -> { - channelManagementViewModel.reconnect() - } + ToolbarAction.Reconnect -> { + channelManagementViewModel.reconnect() + } - ToolbarAction.OpenSettings -> { - onNavigateToSettings() + 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, - streamHeightDp = streamState.heightDp, - totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, - onAction = handleToolbarAction, - 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() }, - 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 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, + streamHeightDp = streamState.heightDp, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, + onAction = handleToolbarAction, + 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() }, + streamToolbarAlpha = streamState.effectiveAlpha, + modifier = toolbarModifier, + ) + } - // Shared pager callbacks - val chatPagerCallbacks = - remember { - ChatPagerCallbacks( - onShowUserPopup = dialogViewModel::showUserPopup, - onMentionUser = chatInputViewModel::mentionUser, - onShowMessageOptions = dialogViewModel::showMessageOptions, - onShowEmoteInfo = dialogViewModel::showEmoteInfo, - onOpenReplies = sheetNavigationViewModel::openReplies, - onRecover = { - if (mainScreenViewModel.uiState.value.isFullscreen) mainScreenViewModel.toggleFullscreen() - if (!mainScreenViewModel.uiState.value.showInput) mainScreenViewModel.toggleInput() - mainScreenViewModel.resetGestureState() + // 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) }, - onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, - onTourAdvance = featureTourViewModel::advance, - onTourSkip = featureTourViewModel::skipTour, - scrollConnection = toolbarTracker, + onBackspace = chatInputViewModel::deleteLastWord, + modifier = menuModifier, ) } - // 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(it) } - } + // Shared pager callbacks + val chatPagerCallbacks = + remember { + ChatPagerCallbacks( + onShowUserPopup = dialogViewModel::showUserPopup, + onMentionUser = chatInputViewModel::mentionUser, + onShowMessageOptions = dialogViewModel::showMessageOptions, + onShowEmoteInfo = dialogViewModel::showEmoteInfo, + onOpenReplies = sheetNavigationViewModel::openReplies, + onRecover = { + if (mainScreenViewModel.uiState.value.isFullscreen) mainScreenViewModel.toggleFullscreen() + if (!mainScreenViewModel.uiState.value.showInput) mainScreenViewModel.toggleInput() + mainScreenViewModel.resetGestureState() + }, + onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, + onTourAdvance = featureTourViewModel::advance, + onTourSkip = featureTourViewModel::skipTour, + scrollConnection = toolbarTracker, + ) + } - InputAction.LastMessage -> { - chatInputViewModel.getLastMessage() - } + // 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(it) } + } - InputAction.Stream -> { - val stream = streamViewModel.streamState.value.currentStream - when { - stream != null -> streamViewModel.closeStream() - else -> channel?.let { streamViewModel.toggleStream(it) } + InputAction.LastMessage -> { + chatInputViewModel.getLastMessage() } - } - InputAction.ModActions -> { - dialogViewModel.showModActions() - } + InputAction.Stream -> { + val stream = streamViewModel.streamState.value.currentStream + when { + stream != null -> streamViewModel.closeStream() + else -> channel?.let { streamViewModel.toggleStream(it) } + } + } - InputAction.Fullscreen -> { - mainScreenViewModel.toggleFullscreen() - } + InputAction.ModActions -> { + dialogViewModel.showModActions() + } - InputAction.HideInput -> { - mainScreenViewModel.toggleInput() - } + InputAction.Fullscreen -> { + mainScreenViewModel.toggleFullscreen() + } - InputAction.Debug -> { - sheetNavigationViewModel.openDebugInfo() + InputAction.HideInput -> { + mainScreenViewModel.toggleInput() + } + + InputAction.Debug -> { + sheetNavigationViewModel.openDebugInfo() + } } } } - } - val fabMenuCallbacks = - FabMenuCallbacks( - onAction = fabActionHandler, - isStreamActive = currentStream != null, - 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, - effectiveShowInput = effectiveShowInput, - isFullscreen = isFullscreen, - 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, - ) - } + val fabMenuCallbacks = + FabMenuCallbacks( + onAction = fabActionHandler, + isStreamActive = currentStream != null, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), + debugMode = mainState.debugMode, + enabled = inputState.enabled, + hasLastMessage = inputState.hasLastMessage, + ) - // Shared fullscreen sheet overlay - val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> - val effectiveBottomPadding = - when { - !effectiveShowInput -> 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 scaffoldContent: @Composable (PaddingValues, Dp) -> Unit = { paddingValues, chatTopPadding -> + MainScreenPagerContent( + paddingValues = paddingValues, + chatTopPadding = chatTopPadding, + tabState = tabState, + composePagerState = composePagerState, + pagerState = pagerState, + isLoggedIn = isLoggedIn, + effectiveShowInput = effectiveShowInput, + isFullscreen = isFullscreen, + 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, + ) + } - if (useWideSplitLayout) { - // --- Wide split layout: stream (left) | handle | chat (right) --- - 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 - Box( - modifier = - Modifier - .weight(splitFraction) - .fillMaxSize(), - ) { - StreamView( - channel = currentStream, - streamViewModel = streamViewModel, - fillPane = true, - onClose = { - keyboardController?.hide() - focusManager.clearFocus() - streamViewModel.closeStream() - }, - modifier = Modifier.fillMaxSize(), - ) + // Shared fullscreen sheet overlay + val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> + val effectiveBottomPadding = + when { + !effectiveShowInput -> 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, + ) + } - // Right pane: Chat + all overlays - Box( - modifier = - Modifier - .weight(1f - splitFraction) - .fillMaxSize(), - ) { - val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + if (useWideSplitLayout) { + // --- Wide split layout: stream (left) | handle | chat (right) --- + var splitFraction by remember { mutableFloatStateOf(0.6f) } + var containerWidthPx by remember { mutableIntStateOf(0) } - Scaffold( + Box( + modifier = + Modifier + .fillMaxSize() + .onSizeChanged { containerWidthPx = it.width }, + ) { + Row(modifier = Modifier.fillMaxSize()) { + // Left pane: Stream + Box( 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 && !inputState.isEmoteMenuOpen && !isSheetOpen, - false, - showTabsInSplit, - ) - - // Status bar scrim when toolbar is gesture-hidden - if (!isFullscreen && mainState.gestureToolbarHidden) { - StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) - } - - fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) - - // Dismiss scrim for input overflow menu - if (inputOverflowExpanded) { - InputDismissScrim( - forceOpen = featureTourState.forceOverflowOpen, - onDismiss = { inputOverflowExpanded = false }, + Modifier + .weight(splitFraction) + .fillMaxSize(), + ) { + StreamView( + channel = currentStream, + streamViewModel = streamViewModel, + fillPane = true, + onClose = { + keyboardController?.hide() + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = Modifier.fillMaxSize(), ) } - // Input bar - rendered after sheet overlay so it's on top + // Right pane: Chat + all overlays Box( modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ), + .weight(1f - splitFraction) + .fillMaxSize(), ) { - bottomBar() - } + val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + Scaffold( + modifier = + modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), + contentWindowInsets = WindowInsets(0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = inputHeightDp), + ) + }, + ) { paddingValues -> + scaffoldContent(paddingValues, statusBarTop) + } - if (effectiveShowInput && isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, + val chatPaneWidthDp = with(density) { (containerWidthPx * (1f - splitFraction)).toInt().toDp() } + val showTabsInSplit = chatPaneWidthDp > 250.dp + + floatingToolbar( + Modifier.align(Alignment.TopCenter), + !isKeyboardVisible && !inputState.isEmoteMenuOpen && !isSheetOpen, + false, + showTabsInSplit, + ) + + // Status bar scrim when toolbar is gesture-hidden + if (!isFullscreen && mainState.gestureToolbarHidden) { + StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) + } + + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + + // Dismiss scrim for input overflow menu + if (inputOverflowExpanded) { + InputDismissScrim( + forceOpen = featureTourState.forceOverflowOpen, + onDismiss = { inputOverflowExpanded = false }, + ) + } + + // Input bar - rendered after sheet overlay so it's on top + Box( modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp), - ) + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ), + ) { + bottomBar() + } + + emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + + if (effectiveShowInput && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = + Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp), + ) + } } } - } - // Draggable handle overlaid at the split edge - DraggableHandle( - onDrag = { deltaPx -> - if (containerWidthPx > 0) { - splitFraction = (splitFraction + deltaPx / containerWidthPx).coerceIn(0.2f, 0.8f) + // Draggable handle overlaid at the split edge + 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() }, + ) + } + } else { + // --- Normal stacked layout (portrait / narrow-without-stream / PiP) --- + 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) + } + } // end !isInPipMode + + // Stream View layer + currentStream?.let { channel -> + val showStream = isInPipMode || !isKeyboardVisible || isLandscape + // Delay adding StreamView to composition to prevent WebView flash on first open. + // If the WebView was already attached (e.g. switching from wide layout), skip the delay. + var streamComposed by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } + LaunchedEffect(showStream) { + if (showStream) { + delay(100) + streamComposed = true + } else { + streamComposed = false } - }, - modifier = - Modifier - .align(Alignment.CenterStart) - .graphicsLayer { translationX = containerWidthPx * splitFraction - 12.dp.toPx() }, - ) - } - } else { - // --- Normal stacked layout (portrait / narrow-without-stream / PiP) --- - if (!isInPipMode) { - Scaffold( - modifier = - modifier - .fillMaxSize() - .padding(bottom = scaffoldBottomPadding), - contentWindowInsets = WindowInsets(0), - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.padding(bottom = inputHeightDp), + } + if (showStream && streamComposed) { + StreamView( + channel = channel, + streamViewModel = streamViewModel, + isInPipMode = isInPipMode, + onClose = { + keyboardController?.hide() + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = + if (isInPipMode) { + Modifier.fillMaxSize() + } else { + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .graphicsLayer { alpha = streamState.alpha.value } + .onSizeChanged { size -> + streamState.heightDp = with(density) { size.height.toDp() } + } + }, ) - }, - ) { paddingValues -> - val chatTopPadding = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamState.heightDp * streamState.alpha.value) - scaffoldContent(paddingValues, chatTopPadding) - } - } // end !isInPipMode - - // Stream View layer - currentStream?.let { channel -> - val showStream = isInPipMode || !isKeyboardVisible || isLandscape - // Delay adding StreamView to composition to prevent WebView flash on first open. - // If the WebView was already attached (e.g. switching from wide layout), skip the delay. - var streamComposed by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } - LaunchedEffect(showStream) { - if (showStream) { - delay(100) - streamComposed = true - } else { - streamComposed = false + } + if (!showStream) { + streamState.heightDp = 0.dp } } - if (showStream && streamComposed) { - StreamView( - channel = channel, - streamViewModel = streamViewModel, - isInPipMode = isInPipMode, - onClose = { - keyboardController?.hide() - focusManager.clearFocus() - streamViewModel.closeStream() - }, + + // Status bar scrim when stream is active — fades with stream/toolbar + if (currentStream != null && !isFullscreen && !isInPipMode) { + StatusBarScrim( + colorAlpha = 1f, modifier = - if (isInPipMode) { - Modifier.fillMaxSize() - } else { - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .graphicsLayer { alpha = streamState.alpha.value } - .onSizeChanged { size -> - streamState.heightDp = with(density) { size.height.toDp() } - } - }, + Modifier + .align(Alignment.TopCenter) + .graphicsLayer { alpha = streamState.alpha.value }, ) } - if (!showStream) { - streamState.heightDp = 0.dp - } - } - // Status bar scrim when stream is active — fades with stream/toolbar - if (currentStream != null && !isFullscreen && !isInPipMode) { - StatusBarScrim( - colorAlpha = 1f, - modifier = - Modifier - .align(Alignment.TopCenter) - .graphicsLayer { alpha = streamState.alpha.value }, - ) - } - - // Floating Toolbars - collapsible tabs (expand on swipe) + actions - if (!isInPipMode) { - floatingToolbar( - Modifier.align(Alignment.TopCenter), - (!isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen)) && !isSheetOpen, - true, - true, - ) - } + // Floating Toolbars - collapsible tabs (expand on swipe) + actions + if (!isInPipMode) { + floatingToolbar( + Modifier.align(Alignment.TopCenter), + (!isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen)) && !isSheetOpen, + true, + true, + ) + } - // Status bar scrim when toolbar is gesture-hidden — keeps status bar readable - if (!isInPipMode && !isFullscreen && mainState.gestureToolbarHidden) { - StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) - } + // Status bar scrim when toolbar is gesture-hidden — keeps status bar readable + if (!isInPipMode && !isFullscreen && mainState.gestureToolbarHidden) { + StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) + } - // Fullscreen Overlay Sheets — after toolbar/scrims so sheets render on top - if (!isInPipMode) { - fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) - } + // Fullscreen Overlay Sheets — after toolbar/scrims so sheets render on top + if (!isInPipMode) { + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + } - // Dismiss scrim for input overflow menu — before input bar so menu items stay clickable - if (!isInPipMode && inputOverflowExpanded) { - InputDismissScrim( - forceOpen = featureTourState.forceOverflowOpen, - onDismiss = { inputOverflowExpanded = false }, - ) - } + // Dismiss scrim for input overflow menu — before input bar so menu items stay clickable + if (!isInPipMode && inputOverflowExpanded) { + InputDismissScrim( + forceOpen = featureTourState.forceOverflowOpen, + onDismiss = { inputOverflowExpanded = false }, + ) + } - // Input bar — on top of sheets and dismiss scrim for whisper/reply input - if (!isInPipMode) { - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ), - ) { - bottomBar() + // Input bar — on top of sheets and dismiss scrim for whisper/reply input + if (!isInPipMode) { + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = { mainScreenViewModel.setGestureInputHidden(true) }, + ), + ) { + bottomBar() + } } - } - // Emote Menu Layer - slides up/down independently of keyboard - // Fast tween to match system keyboard animation speed - if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + // Emote Menu Layer - slides up/down independently of keyboard + // Fast tween to match system keyboard animation speed + if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - if (!isInPipMode && effectiveShowInput && isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = - Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp), - ) + if (!isInPipMode && effectiveShowInput && isKeyboardVisible) { + SuggestionDropdown( + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + modifier = + Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp), + ) + } } } } - } } 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 index a3e147eee..5f546e62d 100644 --- 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 @@ -148,22 +148,22 @@ fun MessageHistorySheet( }, ) { ChatScreen( - messages = messages, - fontSize = displaySettings.fontSize, - callbacks = - ChatScreenCallbacks( - onUserClick = onUserClick, - onMessageLongClick = onMessageLongClick, - onEmoteClick = onEmoteClick, - ), - showLineSeparator = displaySettings.showLineSeparator, - animateGifs = displaySettings.animateGifs, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), - scrollModifier = scrollModifier, - containerColor = sheetBackgroundColor, - onScrollToBottom = { toolbarVisible = true }, - ) + messages = messages, + fontSize = displaySettings.fontSize, + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ), + showLineSeparator = displaySettings.showLineSeparator, + animateGifs = displaySettings.animateGifs, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), + scrollModifier = scrollModifier, + containerColor = sheetBackgroundColor, + onScrollToBottom = { toolbarVisible = true }, + ) AnimatedVisibility( visible = toolbarVisible, From 2ee22b826baaefd714f97f055975fedb39479a63 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 00:31:52 +0200 Subject: [PATCH 195/349] fix(compose): Add @Immutable to RoomState, use ImmutableList in dialogs, move EmoteMenu to emotemenu package, remove run{} in ModActionsDialog --- .../dankchat/data/twitch/message/RoomState.kt | 2 + .../{EmoteSheetItem.kt => EmoteInfoItem.kt} | 2 +- .../ui/chat/emote/EmoteInfoViewModel.kt | 26 +- .../sheet => chat/emotemenu}/EmoteMenu.kt | 5 +- .../dankchat/ui/chat/user/UserPopupDialog.kt | 5 +- .../dankchat/ui/main/MainScreenComponents.kt | 2 +- .../ui/main/dialog/EmoteInfoDialog.kt | 7 +- .../ui/main/dialog/MainScreenDialogs.kt | 3 +- .../ui/main/dialog/ModActionsDialog.kt | 256 +++++++++--------- .../dankchat/ui/main/sheet/EmoteMenuSheet.kt | 145 ---------- 10 files changed, 156 insertions(+), 297 deletions(-) rename app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/{EmoteSheetItem.kt => EmoteInfoItem.kt} (92%) rename app/src/main/kotlin/com/flxrs/dankchat/ui/{main/sheet => chat/emotemenu}/EmoteMenu.kt (98%) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt 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 ee4bea261..9039ba106 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,5 +1,6 @@ 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 @@ -10,6 +11,7 @@ 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, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteSheetItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoItem.kt similarity index 92% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteSheetItem.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoItem.kt index 222d3e776..3b18771e4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteSheetItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoItem.kt @@ -3,7 +3,7 @@ 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 index c6faba21c..2148bc4b7 100644 --- 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 @@ -5,6 +5,7 @@ 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.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @@ -13,18 +14,19 @@ class EmoteInfoViewModel( @InjectedParam private val emotes: List, ) : ViewModel() { val items = - 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(), - ) - } + 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) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenu.kt similarity index 98% rename from app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenu.kt index 648521b8e..f9c6723f7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenu.kt @@ -1,4 +1,4 @@ -package com.flxrs.dankchat.ui.main.sheet +package com.flxrs.dankchat.ui.chat.emotemenu import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -43,8 +43,7 @@ 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.chat.emotemenu.EmoteItem -import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenuTab +import com.flxrs.dankchat.ui.main.sheet.EmoteMenuViewModel import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel 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 index fb90ec107..e8e1df588 100644 --- 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 @@ -60,12 +60,13 @@ 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: List, + badges: ImmutableList, onBlockUser: () -> Unit, onUnblockUser: () -> Unit, onDismiss: () -> Unit, @@ -239,7 +240,7 @@ private fun UserInfoSection( state: UserPopupState, userName: UserName, displayName: DisplayName, - badges: List, + badges: ImmutableList, onOpenChannel: (String) -> Unit, ) { Row( 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 index f46cca6d3..31c9554f2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -47,7 +47,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.flxrs.dankchat.ui.main.sheet.EmoteMenu +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenu import com.flxrs.dankchat.ui.main.stream.StreamViewModel @Composable 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 index 9a46bd354..6ff8938eb 100644 --- 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 @@ -36,13 +36,14 @@ 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.ui.chat.emote.EmoteSheetItem +import com.flxrs.dankchat.ui.chat.emote.EmoteInfoItem +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun EmoteInfoDialog( - items: List, + items: ImmutableList, isLoggedIn: Boolean, onUseEmote: (String) -> Unit, onCopyEmote: (String) -> Unit, @@ -101,7 +102,7 @@ fun EmoteInfoDialog( @Composable private fun EmoteInfoContent( - item: EmoteSheetItem, + item: EmoteInfoItem, showUseEmote: Boolean, onUseEmote: () -> Unit, onCopyEmote: () -> Unit, 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 index 8c4f8d3dc..99380106f 100644 --- 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 @@ -51,6 +51,7 @@ 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 @@ -312,7 +313,7 @@ fun MainScreenDialogs( val state by viewModel.userPopupState.collectAsStateWithLifecycle() UserPopupDialog( state = state, - badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }, + badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }.toImmutableList(), isOwnUser = viewModel.isOwnUser, onBlockUser = viewModel::blockUser, onUnblockUser = viewModel::unblockUser, 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 index 44acc36f4..8b1210d13 100644 --- 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 @@ -123,144 +123,142 @@ fun ModActionsDialog( ) { var subView by remember { mutableStateOf(null) } - run { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - AnimatedContent( - targetState = subView, - transitionSpec = { - when { - targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() - } - }, - 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, - ) - } + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + AnimatedContent( + targetState = subView, + transitionSpec = { + when { + targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() + else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + } + }, + 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.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.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.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.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.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.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.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.ShieldModeConfirm -> { + ShieldModeConfirmSubView( + onConfirm = { + onSendCommand("/shield") + onDismiss() + }, + onBack = { subView = null }, + ) + } - SubView.ClearChatConfirm -> { - ClearChatConfirmSubView( - onConfirm = { - onSendCommand("/clear") - onDismiss() - }, - onBack = { subView = null }, - ) - } + SubView.ClearChatConfirm -> { + ClearChatConfirmSubView( + onConfirm = { + onSendCommand("/clear") + onDismiss() + }, + onBack = { subView = null }, + ) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt deleted file mode 100644 index 4151e4cdc..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuSheet.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.flxrs.dankchat.ui.main.sheet - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.aspectRatio -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.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.SheetState -import androidx.compose.material3.Tab -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.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import com.flxrs.dankchat.R -import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem -import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenuTab -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EmoteMenuSheet( - onDismiss: () -> Unit, - onEmoteClick: (String, String) -> Unit, - sheetState: SheetState, - viewModel: EmoteMenuViewModel = koinViewModel(), -) { - val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - val pagerState = - rememberPagerState( - initialPage = 0, - pageCount = { tabItems.size }, - ) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - modifier = Modifier.height(400.dp), // Fixed height for emote menu - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Column(modifier = Modifier.fillMaxSize()) { - PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { - 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) - }, - ) - }, - ) - } - } - - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - beyondViewportPageCount = 1, - ) { page -> - val items = tabItems[page].items - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 40.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(8.dp), - 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) }, - ) - } - } - } - } - } - } - } -} From 99991efef18cfb912892454e2432d802bb4951f2 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 01:06:08 +0200 Subject: [PATCH 196/349] cleanup: Code review fixes, fix SuggestionFilteringTest for renamed filterEmotesScored --- .../data/repo/HighlightsRepository.kt | 2 -- .../dankchat/data/repo/IgnoresRepository.kt | 2 -- .../repo/chat/ChatNotificationRepository.kt | 2 -- .../data/repo/emote/EmoteRepository.kt | 1 - .../data/twitch/pubsub/PubSubConnection.kt | 3 +-- .../dankchat/domain/ChannelDataCoordinator.kt | 21 ------------------ .../preferences/DankChatPreferenceStore.kt | 7 ------ .../chat/message/MessageOptionsViewModel.kt | 3 +-- .../ui/chat/messages/SystemMessages.kt | 4 ---- .../ui/chat/suggestion/SuggestionProvider.kt | 9 +------- .../flxrs/dankchat/ui/main/MainActivity.kt | 12 ++-------- .../com/flxrs/dankchat/ui/main/MainEvent.kt | 12 ---------- .../flxrs/dankchat/ui/main/MainEventBus.kt | 10 --------- .../channel/ChannelManagementViewModel.kt | 5 ----- .../ui/main/sheet/EmoteMenuViewModel.kt | 4 ++-- .../ui/main/sheet/SheetNavigationViewModel.kt | 21 ------------------ .../dankchat/ui/main/stream/StreamWebView.kt | 15 ------------- .../ui/onboarding/OnboardingViewModel.kt | 4 ---- .../dankchat/ui/share/ShareUploadActivity.kt | 2 +- .../dankchat/ui/tour/FeatureTourViewModel.kt | 6 +---- .../suggestion/SuggestionFilteringTest.kt | 22 +++++++++---------- 21 files changed, 19 insertions(+), 148 deletions(-) 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 ed0ab17a0..d61488c1d 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,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.flxrs.dankchat.data.repo import android.util.Log 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 e88ec8521..3fe453732 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,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.flxrs.dankchat.data.repo import android.util.Log 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 index d50e45148..57ea6084b 100644 --- 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 @@ -50,8 +50,6 @@ class ChatNotificationRepository( val notificationsFlow: SharedFlow> = _notificationsFlow.asSharedFlow() val channelMentionCount: SharedFlow> = _channelMentionCount.asSharedFlow() val unreadMessagesMap: SharedFlow> = _unreadMessagesMap.asSharedFlow() - val hasMentions = channelMentionCount.map { it.any { (key, value) -> key != WhisperMessage.WHISPER_CHANNEL && value > 0 } } - val hasWhispers = channelMentionCount.map { it.getOrDefault(WhisperMessage.WHISPER_CHANNEL, 0) > 0 } val mentions: StateFlow> = _mentions val whispers: StateFlow> = _whispers 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 d0539d32e..18edba9c4 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 @@ -94,7 +94,6 @@ class EmoteRepository( channelEmoteStates.remove(channel) } - /** Clears user-specific Twitch emotes (subscriber, bit, follower) from global state. */ fun clearTwitchEmotes() { globalEmoteState.update { it.copy(twitchEmotes = emptyList()) } channelEmoteStates.values.forEach { state -> 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 c78db059b..2993bd885 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 @@ -120,8 +120,7 @@ class PubSubConnection( val text = when (val frame = result.getOrNull()) { null -> { - val cause = result.exceptionOrNull() - if (cause == null) return@webSocket + val cause = result.exceptionOrNull() ?: return@webSocket throw cause } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index c2e14bc6e..3d3caac9d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -88,9 +88,6 @@ class ChannelDataCoordinator( MutableStateFlow(ChannelLoadingState.Idle) } - /** - * Load data when a channel is added - */ fun loadChannelData(channel: UserName) { scope.launch { loadChannelDataSuspend(channel) @@ -108,9 +105,6 @@ class ChannelDataCoordinator( chatMessageRepository.reparseAllEmotesAndBadges() } - /** - * Load global data (once at startup) - */ fun loadGlobalData() { globalLoadJob = scope.launch { @@ -166,17 +160,11 @@ class ChannelDataCoordinator( } } - /** - * Cancel ongoing global data loading (e.g., on logout) - */ fun cancelGlobalLoading() { globalLoadJob?.cancel() globalLoadJob = null } - /** - * Cleanup when a channel is removed - */ fun cleanupChannel(channel: UserName) { channelStates.remove(channel) scope.launch { @@ -184,9 +172,6 @@ class ChannelDataCoordinator( } } - /** - * Reload all channels (e.g., on reconnect) - */ fun reloadAllChannels() { scope.launch { preferenceStore.channels.forEach { channel -> @@ -210,16 +195,10 @@ class ChannelDataCoordinator( } } - /** - * Reload global data - */ fun reloadGlobalData() { loadGlobalData() } - /** - * Retry specific failed data and chat steps - */ fun retryDataLoading(failedState: GlobalLoadingState.Failed) { scope.launch { _globalLoadingState.value = GlobalLoadingState.Loading 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 275163790..9ae62499b 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 @@ -11,7 +9,6 @@ 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.AuthSettings import com.flxrs.dankchat.data.toUserNames import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.model.ChannelWithRename @@ -117,10 +114,6 @@ class DankChatPreferenceStore( else -> context.resources.getQuantityString(R.plurals.viewers_and_uptime_with_cateogry, viewers, viewers, category, uptime) } - fun clearLogin() { - authDataStore.updateAsync { it.copy(oAuthKey = null, userName = null, displayName = null, userId = null, clientId = AuthSettings.DEFAULT_CLIENT_ID, isLoggedIn = false) } - } - fun removeChannel(channel: UserName): List { val updated = channels - channel dankChatPreferences.edit { 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 index da0d40a26..44f5b41e4 100644 --- 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 @@ -59,13 +59,12 @@ class MessageOptionsViewModel( val thread = asPrivMessage?.thread val rootId = thread?.rootId val name = asPrivMessage?.name ?: asWhisperMessage?.name ?: return@combine MessageOptionsState.NotFound - val replyName = name val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage MessageOptionsState.Found( messageId = message.id, rootThreadId = rootId ?: message.id, rootThreadName = thread?.name, - replyName = replyName, + replyName = name, name = name, originalMessage = originalMessage.orEmpty(), canModerate = canModerateParam && channel != null && channel in userState.moderationChannels, 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 index 811f14b1e..3175a0a96 100644 --- 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 @@ -15,7 +15,6 @@ 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.LocalContext import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -26,7 +25,6 @@ import androidx.compose.ui.unit.sp import com.flxrs.dankchat.ui.chat.ChatMessageUiState import com.flxrs.dankchat.ui.chat.messages.common.SimpleMessageContainer 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.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.messages.common.rememberBackgroundColor @@ -79,7 +77,6 @@ fun NoticeMessageComposable( * Renders a user notice message (subscriptions, announcements, etc.) * The display name is highlighted with the user's color. */ -@Suppress("DEPRECATION") @Composable fun UserNoticeMessageComposable( message: ChatMessageUiState.UserNoticeMessageUi, @@ -195,7 +192,6 @@ private data class StyledRange( /** * Renders a moderation message (timeouts, bans, deletions) with colored usernames. */ -@Suppress("DEPRECATION") @Composable fun ModerationMessageComposable( message: ChatMessageUiState.ModerationMessageUi, 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 index a79c37ae8..7b5fc0875 100644 --- 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 @@ -173,14 +173,7 @@ class SuggestionProvider( return caseCost + extraChars * 100 + usageBoost } - // Score raw GenericEmotes, only wrap matches - internal fun filterEmotes( - emotes: List, - constraint: String, - recentEmoteIds: Set, - ): List = filterEmotesScored(emotes, constraint, recentEmoteIds).map { it.suggestion as Suggestion.EmoteSuggestion } - - private fun filterEmotesScored( + internal fun filterEmotesScored( emotes: List, constraint: String, recentEmoteIds: Set, 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 index c2877fdab..303c676ad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -134,8 +134,8 @@ class MainActivity : AppCompatActivity() { } private val twitchServiceConnection = TwitchServiceConnection() - var notificationService: NotificationService? = null - var isBound = false + private var notificationService: NotificationService? = null + private var isBound = false override fun onCreate(savedInstanceState: Bundle?) { val isTrueDarkModeEnabled = viewModel.isTrueDarkModeEnabled @@ -537,14 +537,6 @@ class MainActivity : AppCompatActivity() { } } - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - newConfig: android.content.res.Configuration, - ) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - mainEventBus.setInPipMode(isInPictureInPictureMode) - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val channelExtra = intent.parcelable(OPEN_CHANNEL_KEY) ?: return 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 index 51611366f..03cead045 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt @@ -22,18 +22,6 @@ sealed interface MainEvent { val imageCapture: Boolean, ) : MainEvent - data class LoginValidated( - val username: UserName, - ) : MainEvent - - data class LoginOutdated( - val username: UserName, - ) : MainEvent - - data object LoginTokenInvalid : MainEvent - - data object LoginValidationFailed : MainEvent - data class OpenChannel( val channel: UserName, ) : 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 index 279d49413..506bddf3e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt @@ -1,9 +1,6 @@ package com.flxrs.dankchat.ui.main import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import org.koin.core.annotation.Single @@ -12,13 +9,6 @@ class MainEventBus { private val _events = Channel(Channel.BUFFERED) val events = _events.receiveAsFlow() - private val _isInPipMode = MutableStateFlow(false) - val isInPipMode: StateFlow = _isInPipMode.asStateFlow() - - fun setInPipMode(value: Boolean) { - _isInPipMode.value = value - } - suspend fun emitEvent(event: MainEvent) { _events.send(event) } 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 index 440b49417..9f5f7565e 100644 --- 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 @@ -30,7 +30,6 @@ class ChannelManagementViewModel( private val chatRepository: ChatRepository, private val chatChannelProvider: ChatChannelProvider, private val chatConnector: ChatConnector, - private val chatMessageRepository: ChatMessageRepository, private val chatNotificationRepository: ChatNotificationRepository, private val ignoresRepository: IgnoresRepository, private val channelRepository: ChannelRepository, @@ -115,10 +114,6 @@ class ChannelManagementViewModel( chatConnector.reconnect() } - fun clearChat(channel: UserName) { - chatMessageRepository.clearMessages(channel) - } - fun blockChannel(channel: UserName) = viewModelScope.launch { runCatching { 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 index d8d6fd4b5..dfdf4b117 100644 --- 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 @@ -27,9 +27,9 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class EmoteMenuViewModel( - private val chatChannelProvider: ChatChannelProvider, private val dataRepository: DataRepository, - private val emoteUsageRepository: EmoteUsageRepository, + chatChannelProvider: ChatChannelProvider, + emoteUsageRepository: EmoteUsageRepository, ) : ViewModel() { private val activeChannel = chatChannelProvider.activeChannel 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 index a100eeb12..c80f57cc1 100644 --- 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 @@ -52,10 +52,6 @@ class SheetNavigationViewModel : ViewModel() { _fullScreenSheetState.value = FullScreenSheetState.Closed } - fun openEmoteSheet() { - _inputSheetState.value = InputSheetState.EmoteMenu - } - fun openDebugInfo() { _inputSheetState.value = InputSheetState.DebugInfo } @@ -63,21 +59,4 @@ class SheetNavigationViewModel : ViewModel() { fun closeInputSheet() { _inputSheetState.value = InputSheetState.Closed } - - fun handleBackPress(): Boolean = - when { - _inputSheetState.value != InputSheetState.Closed -> { - closeInputSheet() - true - } - - _fullScreenSheetState.value != FullScreenSheetState.Closed -> { - closeFullScreenSheet() - true - } - - else -> { - 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 index 6cdbfa99d..14bdb4c34 100644 --- 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 @@ -6,8 +6,6 @@ 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 @@ -28,19 +26,6 @@ class StreamWebView 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( 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 index 328de2039..1a91b3327 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt @@ -67,10 +67,6 @@ class OnboardingViewModel( } } - fun onLoginCompleted() { - _state.update { it.copy(loginCompleted = true) } - } - fun onMessageHistoryDecision(enabled: Boolean) { _state.update { it.copy(messageHistoryDecided = true, messageHistoryEnabled = enabled) } } 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 index e6e996c5b..18fccb0c7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt @@ -126,7 +126,7 @@ class ShareUploadActivity : ComponentActivity() { onFailure = { error -> uploadState = ShareUploadState.Error( - error.message ?: getString(R.string.snackbar_upload_failed), + message = error.message ?: getString(R.string.snackbar_upload_failed), ) }, ) 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 index 4e0c80b44..bb60b18a5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt @@ -59,7 +59,7 @@ class FeatureTourViewModel( private val onboardingDataStore: OnboardingDataStore, startupValidationHolder: StartupValidationHolder, ) : ViewModel() { - // Material3 tooltip states — UI objects exposed directly, not in the StateFlow. + // Material3 tooltip states val inputActionsTooltipState = TooltipState(isPersistent = true) val overflowMenuTooltipState = TooltipState(isPersistent = true) val configureActionsTooltipState = TooltipState(isPersistent = true) @@ -125,8 +125,6 @@ class FeatureTourViewModel( _channelState.value = ChannelState(ready = ready, empty = empty) } - // -- Toolbar hint callbacks -- - /** User already used the toolbar + icon, no need to show the hint. */ fun onAddedChannelFromToolbar() { if (_toolbarHintDone.value) return @@ -144,8 +142,6 @@ class FeatureTourViewModel( } } - // -- Tour lifecycle -- - fun startTour() { val tour = _tourState.value if (tour.isActive || tour.completed) return 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 index 41c5e0172..899af185f 100644 --- 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 @@ -24,64 +24,62 @@ internal class SuggestionFilteringTest { id: String = code, ) = GenericEmote(code = code, url = "", lowResUrl = "", id = id, scale = 1, emoteType = EmoteType.GlobalTwitchEmote) - // region filterEmotes - @Test fun `emotes sorted by score - shorter before longer`() { val emotes = listOf(emote("PogChamp"), emote("PogU"), emote("Pog")) - val result = provider.filterEmotes(emotes, "Pog", emptySet()) + val result = provider.filterEmotesScored(emotes, "Pog", emptySet()) assertEquals( expected = listOf("Pog", "PogU", "PogChamp"), - actual = result.map { it.emote.code }, + 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.filterEmotes(emotes, "Pog", emptySet()) + 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.emote.code }, + 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.filterEmotes(emotes, "wi", emptySet()) + 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.emote.code }, + 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.filterEmotes(emotes, "Pog", setOf("1")) + 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.emote.code }, + 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.filterEmotes(emotes, "Pog", emptySet()) + val result = provider.filterEmotesScored(emotes, "Pog", emptySet()) assertEquals( expected = listOf("PogChamp"), - actual = result.map { it.emote.code }, + actual = result.map { (it.suggestion as Suggestion.EmoteSuggestion).emote.code }, ) } From 2c5fca9bca7f83dfc4069244309a6ad9de077b14 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 01:06:25 +0200 Subject: [PATCH 197/349] feat(i18n): Localize all command response strings with translations --- .../dankchat/data/repo/chat/ChatRepository.kt | 10 +- .../data/repo/command/CommandRepository.kt | 28 +- .../data/repo/command/CommandResult.kt | 5 +- .../twitch/command/TwitchCommandRepository.kt | 262 ++++++++---------- .../data/twitch/message/SystemMessageType.kt | 3 +- .../dankchat/ui/chat/ChatMessageMapper.kt | 2 +- .../main/res/values-b+zh+Hant+TW/strings.xml | 116 ++++++++ app/src/main/res/values-be-rBY/strings.xml | 116 ++++++++ app/src/main/res/values-ca/strings.xml | 116 ++++++++ app/src/main/res/values-cs/strings.xml | 116 ++++++++ app/src/main/res/values-de-rDE/strings.xml | 116 ++++++++ app/src/main/res/values-en-rAU/strings.xml | 116 ++++++++ app/src/main/res/values-en-rGB/strings.xml | 116 ++++++++ app/src/main/res/values-en/strings.xml | 116 ++++++++ app/src/main/res/values-es-rES/strings.xml | 116 ++++++++ app/src/main/res/values-fi-rFI/strings.xml | 116 ++++++++ app/src/main/res/values-fr-rFR/strings.xml | 116 ++++++++ app/src/main/res/values-hu-rHU/strings.xml | 116 ++++++++ app/src/main/res/values-it/strings.xml | 116 ++++++++ app/src/main/res/values-ja-rJP/strings.xml | 116 ++++++++ app/src/main/res/values-kk-rKZ/strings.xml | 116 ++++++++ app/src/main/res/values-or-rIN/strings.xml | 116 ++++++++ app/src/main/res/values-pl-rPL/strings.xml | 116 ++++++++ app/src/main/res/values-pt-rBR/strings.xml | 116 ++++++++ app/src/main/res/values-pt-rPT/strings.xml | 116 ++++++++ app/src/main/res/values-ru-rRU/strings.xml | 116 ++++++++ app/src/main/res/values-sr/strings.xml | 116 ++++++++ app/src/main/res/values-tr-rTR/strings.xml | 116 ++++++++ app/src/main/res/values-uk-rUA/strings.xml | 116 ++++++++ app/src/main/res/values/strings.xml | 116 ++++++++ 30 files changed, 2935 insertions(+), 159 deletions(-) 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 93fa9dfa5..a32f79062 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 @@ -13,6 +13,7 @@ import com.flxrs.dankchat.data.twitch.message.SystemMessageType import com.flxrs.dankchat.data.twitch.message.WhisperMessage import com.flxrs.dankchat.data.twitch.message.toChatItem import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.TextResource import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import org.koin.core.annotation.Single @@ -138,12 +139,19 @@ class ChatRepository( } fun makeAndPostCustomSystemMessage( - msg: String, + msg: TextResource, channel: UserName, ) { chatMessageRepository.addSystemMessage(channel, SystemMessageType.Custom(msg)) } + fun makeAndPostCustomSystemMessage( + msg: String, + channel: UserName, + ) { + makeAndPostCustomSystemMessage(TextResource.Plain(msg), channel) + } + private fun partChannel(channel: UserName) { chatMessageRepository.removeMessageFlows(channel) chatConnector.removeConnectionState(channel) 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 3b01b40a0..ab6c25a14 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,6 +1,7 @@ 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 @@ -18,6 +19,8 @@ import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.CustomCommand import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils.calculateUptime +import com.flxrs.dankchat.utils.TextResource +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -149,7 +152,7 @@ class CommandRepository( ?.takeIf { authDataStore.isLoggedIn } ?: return CommandResult.AcceptedTwitchCommand( command = twitchCommand, - response = "You must be logged in to use the $trigger command", + response = TextResource.Res(R.string.cmd_error_not_logged_in, persistentListOf(trigger)), ) twitchCommandRepository.sendWhisper(twitchCommand, currentUserId, trigger, args) } @@ -232,47 +235,47 @@ 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!") + .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!") + 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!") + .getOrNull() ?: return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_unblock_not_found, persistentListOf(target.toString()))) val result = runCatching { ignoresRepository.removeUserBlock(targetId, target) - CommandResult.AcceptedWithResponse("You successfully unblocked user $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()))) } } @@ -281,10 +284,10 @@ class CommandRepository( helixApiClient .getStreams(listOf(channel)) .getOrNull() - ?.getOrNull(0) ?: return CommandResult.AcceptedWithResponse("Channel is not live.") + ?.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( @@ -297,8 +300,7 @@ class CommandRepository( .plus(defaultCommandTriggers) .joinToString(separator = " ") - val response = "Commands available to you in this room: $commands" - return CommandResult.AcceptedWithResponse(response) + return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_help_response, persistentListOf(commands))) } private fun checkUserCommands(trigger: String): CommandResult { 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 cf61bb90a..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,17 +1,18 @@ 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, + val response: TextResource? = null, ) : CommandResult data class AcceptedWithResponse( - val response: String, + val response: TextResource, ) : CommandResult data class Message( 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 746fceeaa..c33d6fc18 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,7 @@ 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 @@ -21,6 +22,8 @@ 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.utils.DateTimeUtils +import com.flxrs.dankchat.utils.TextResource +import kotlinx.collections.immutable.persistentListOf import org.koin.core.annotation.Single import java.util.UUID @@ -61,7 +64,7 @@ class TwitchCommandRepository( val currentUserId = authDataStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( command = command, - response = "You must be logged in to use the ${context.trigger} command", + response = TextResource.Res(R.string.cmd_error_not_logged_in, persistentListOf(context.trigger)), ) return when (command) { @@ -147,20 +150,20 @@ class TwitchCommandRepository( 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.") + 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) }, ) @@ -173,7 +176,7 @@ class TwitchCommandRepository( ): 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(" ") @@ -190,7 +193,7 @@ class TwitchCommandRepository( 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) }, ) @@ -204,19 +207,17 @@ class TwitchCommandRepository( onSuccess = { result -> when { result.isEmpty() -> { - CommandResult.AcceptedTwitchCommand(command, response = "This channel does not have any moderators.") + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_no_moderators)) } else -> { val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } - CommandResult.AcceptedTwitchCommand(command, response = "The moderators of this channel are $users.") + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_moderators_list, persistentListOf(users))) } } - 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)}" + val response = TextResource.Res(R.string.cmd_fail_list_moderators, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) }, ) @@ -227,20 +228,20 @@ class TwitchCommandRepository( ): 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.") + 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) }, ) @@ -252,20 +253,20 @@ class TwitchCommandRepository( ): 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.") + 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) }, ) @@ -279,17 +280,17 @@ class TwitchCommandRepository( onSuccess = { result -> when { result.isEmpty() -> { - CommandResult.AcceptedTwitchCommand(command, response = "This channel does not have any VIPs.") + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_no_vips)) } else -> { val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } - CommandResult.AcceptedTwitchCommand(command, response = "The vips of this channel are $users.") + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_vips_list, persistentListOf(users))) } } }, onFailure = { - val response = "Failed to list VIPs - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_list_vips, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) }, ) @@ -300,20 +301,20 @@ class TwitchCommandRepository( ): 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.") + 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) }, ) @@ -325,20 +326,20 @@ class TwitchCommandRepository( ): 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.") + 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) }, ) @@ -351,21 +352,18 @@ class TwitchCommandRepository( ): 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.") + 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 } @@ -376,7 +374,7 @@ 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) }, ) @@ -389,20 +387,19 @@ class TwitchCommandRepository( ): 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.") + 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) }, ) @@ -414,27 +411,20 @@ class TwitchCommandRepository( 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.") + 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 = @@ -449,7 +439,7 @@ 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) }, ) @@ -463,7 +453,7 @@ class TwitchCommandRepository( helixApiClient.deleteMessages(context.channelId, currentUserId).fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, onFailure = { - val response = "Failed to delete chat messages - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_clear, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) }, ) @@ -475,19 +465,19 @@ class TwitchCommandRepository( ): 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) }, ) @@ -500,17 +490,16 @@ class TwitchCommandRepository( ): 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) }, ) @@ -530,11 +519,11 @@ class TwitchCommandRepository( 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) }, ) @@ -545,7 +534,7 @@ class TwitchCommandRepository( 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) } @@ -554,14 +543,11 @@ 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.Res(R.string.cmd_success_commercial, 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) }, ) @@ -573,19 +559,18 @@ class TwitchCommandRepository( ): 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) + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_raid)) } 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_raid_invalid_username, persistentListOf(args.first()))) } return helixApiClient.postRaid(context.channelId, target.id).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You started to raid ${target.displayName}.") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_raid, persistentListOf(target.displayName.toString()))) }, onFailure = { - val response = "Failed to start a raid - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_raid, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) }, ) @@ -596,9 +581,9 @@ class TwitchCommandRepository( context: CommandContext, ): CommandResult = helixApiClient.deleteRaid(context.channelId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You cancelled the raid.") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_unraid)) }, onFailure = { - val response = "Failed to cancel the raid - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_unraid, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) }, ) @@ -609,10 +594,7 @@ class TwitchCommandRepository( 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 { @@ -621,7 +603,7 @@ class TwitchCommandRepository( } 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) @@ -638,7 +620,7 @@ class TwitchCommandRepository( 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) @@ -651,7 +633,7 @@ class TwitchCommandRepository( 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) @@ -664,7 +646,7 @@ class TwitchCommandRepository( 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) @@ -677,7 +659,7 @@ class TwitchCommandRepository( 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) @@ -690,7 +672,7 @@ class TwitchCommandRepository( 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) @@ -703,7 +685,7 @@ class TwitchCommandRepository( 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) @@ -716,7 +698,7 @@ class TwitchCommandRepository( 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) @@ -731,14 +713,11 @@ class TwitchCommandRepository( 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) @@ -753,7 +732,7 @@ class TwitchCommandRepository( 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) @@ -770,7 +749,7 @@ class TwitchCommandRepository( helixApiClient.patchChatSettings(context.channelId, currentUserId, request).fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, onFailure = { - val response = "Failed to update - ${it.toErrorMessage(command, formatRange = formatRange)}" + val response = TextResource.Res(R.string.cmd_fail_chat_settings, persistentListOf(it.toErrorMessage(command, formatRange = formatRange))) CommandResult.AcceptedTwitchCommand(command, response) }, ) @@ -782,18 +761,18 @@ class TwitchCommandRepository( ): 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.") + 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) }, ) @@ -811,13 +790,13 @@ class TwitchCommandRepository( onSuccess = { val response = when { - it.isActive -> "Shield mode was activated." - else -> "Shield mode was deactivated." + 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) }, ) @@ -827,143 +806,145 @@ class TwitchCommandRepository( command: TwitchCommand, targetUser: DisplayName? = null, formatRange: ((IntRange) -> String)? = null, - ): String { + ): TextResource { Log.v(TAG, "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." + TextResource.Res(R.string.cmd_error_no_permission) } HelixError.Forwarded -> { - message ?: GENERIC_ERROR_MESSAGE + message?.let { TextResource.Plain(it) } ?: TextResource.Res(R.string.cmd_error_unknown) } HelixError.MissingScopes -> { - "Missing required scope. Re-login with your account and try again." + TextResource.Res(R.string.cmd_error_missing_scopes) } HelixError.NotLoggedIn -> { - "Missing login credentials. Re-login with your account and try again." + TextResource.Res(R.string.cmd_error_not_logged_in_credentials) } HelixError.WhisperSelf -> { - "You cannot whisper yourself." + TextResource.Res(R.string.cmd_error_whisper_self) } 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" + TextResource.Res(R.string.cmd_error_no_verified_phone) } HelixError.RecipientBlockedUser -> { - "The recipient doesn't allow whispers from strangers or you directly." + TextResource.Res(R.string.cmd_error_recipient_blocked) } HelixError.RateLimited -> { - "You are being rate-limited by Twitch. Try again in a few seconds." + TextResource.Res(R.string.cmd_error_rate_limited) } 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." + TextResource.Res(R.string.cmd_error_whisper_rate_limited) } HelixError.BroadcasterTokenRequired -> { - "Due to Twitch restrictions, this command can only be used by the broadcaster. Please use the Twitch website instead." + TextResource.Res(R.string.cmd_error_broadcaster_required) } HelixError.TargetAlreadyModded -> { - "${targetUser ?: "The target user"} is already a moderator of this channel." + TextResource.Res(R.string.cmd_error_already_modded, persistentListOf(target)) } HelixError.TargetIsVip -> { - "${targetUser ?: "The target user"} is currently a VIP, /unvip them and retry this command." + TextResource.Res(R.string.cmd_error_target_is_vip, persistentListOf(target)) } HelixError.TargetNotModded -> { - "${targetUser ?: "The target user"} is not a moderator of this channel." + TextResource.Res(R.string.cmd_error_not_modded, persistentListOf(target)) } HelixError.TargetNotBanned -> { - "${targetUser ?: "The target user"} is not banned from this channel." + TextResource.Res(R.string.cmd_error_not_banned, persistentListOf(target)) } HelixError.TargetAlreadyBanned -> { - "${targetUser ?: "The target user"} is already banned in this channel." + TextResource.Res(R.string.cmd_error_already_banned, persistentListOf(target)) } HelixError.TargetCannotBeBanned -> { - "You cannot ${command.trigger} ${targetUser ?: "this user"}." + TextResource.Res(R.string.cmd_error_cannot_perform, persistentListOf(command.trigger, target)) } HelixError.ConflictingBanOperation -> { - "There was a conflicting ban operation on this user. Please try again." + TextResource.Res(R.string.cmd_error_conflicting_ban) } 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." + TextResource.Res(R.string.cmd_error_invalid_color, persistentListOf(VALID_HELIX_COLORS.joinToString())) } is HelixError.MarkerError -> { - error.message ?: GENERIC_ERROR_MESSAGE + error.message?.let { TextResource.Plain(it) } ?: TextResource.Res(R.string.cmd_error_unknown) } HelixError.CommercialNotStreaming -> { - "You must be streaming live to run commercials." + TextResource.Res(R.string.cmd_error_commercial_not_streaming) } HelixError.CommercialRateLimited -> { - "You must wait until your cool-down period expires before you can run another commercial." + TextResource.Res(R.string.cmd_error_commercial_rate_limited) } HelixError.MissingLengthParameter -> { - "Command must include a desired commercial break length that is greater than zero." + TextResource.Res(R.string.cmd_error_missing_length) } HelixError.NoRaidPending -> { - "You don't have an active raid." + TextResource.Res(R.string.cmd_error_no_raid_pending) } HelixError.RaidSelf -> { - "A channel cannot raid itself." + TextResource.Res(R.string.cmd_error_raid_self) } HelixError.ShoutoutSelf -> { - "The broadcaster may not give themselves a Shoutout." + TextResource.Res(R.string.cmd_error_shoutout_self) } HelixError.ShoutoutTargetNotStreaming -> { - "The broadcaster is not streaming live or does not have one or more viewers." + 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 -> { - "The message has already been processed." + TextResource.Res(R.string.cmd_error_message_already_processed) } HelixError.MessageNotFound -> { - "The target message was not found." + TextResource.Res(R.string.cmd_error_message_not_found) } HelixError.MessageTooLarge -> { - "Your message was too long." + TextResource.Res(R.string.cmd_error_message_too_large) } HelixError.ChatMessageRateLimited -> { - "You are being rate-limited. Try again in a moment." + TextResource.Res(R.string.cmd_error_chat_rate_limited) } HelixError.Unknown -> { - GENERIC_ERROR_MESSAGE + TextResource.Res(R.string.cmd_error_unknown) } } } @@ -978,7 +959,6 @@ class TwitchCommandRepository( 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", 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 7baa4b942..003e05fc7 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 @@ -4,6 +4,7 @@ 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 @@ -62,7 +63,7 @@ sealed interface SystemMessageType { ) : SystemMessageType data class Custom( - val message: String, + val message: TextResource, ) : SystemMessageType data object SendNotLoggedIn : SystemMessageType 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 index 4195c7039..afe556738 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -192,7 +192,7 @@ class ChatMessageMapper( } is SystemMessageType.Custom -> { - TextResource.Plain(type.message) + type.message } is SystemMessageType.Debug -> { 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 c2bf38289..eba6cce8d 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 @@ -665,4 +665,120 @@ 訊息過長 已被限速,請稍後再試 發送失敗:%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。 + 該訊息已被處理。 + 找不到目標訊息。 + 您的訊息太長了。 + 您被限速了。請稍後再試。 + 目標使用者 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 8215c26a4..fb0de5978 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -682,4 +682,120 @@ Паведамленне занадта вялікае Перавышаны ліміт запытаў, паспрабуйце пазней Памылка адпраўкі: %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$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. + Паведамленне ўжо было апрацавана. + Мэтавае паведамленне не знойдзена. + Ваша паведамленне занадта доўгае. + Вашы запыты абмежаваныя. Паспрабуйце зноў праз хвіліну. + Мэтавы карыстальнік diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index bc78a49c1..4450d15fb 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -708,4 +708,120 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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. + 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 5e5ccd5a2..fd7692842 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -683,4 +683,120 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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. + 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 79b6083ad..9845b6e20 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -681,4 +681,120 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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. + 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index cea44c3ed..f4d538fb6 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -660,4 +660,120 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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. + 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-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index a37728d5d..f48b00ac4 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -660,4 +660,120 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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. + 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-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4be65d8cf..89c9c5de0 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -675,4 +675,120 @@ 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. + 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-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index e5cc6cc00..d1929a41b 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -691,4 +691,120 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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. + 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 1524d14ab..d4f92bd74 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -683,4 +683,120 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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. + 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ä diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 050139160..ef81e0c0f 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -675,4 +675,120 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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. + É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é diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index a53eb9cc9..b5e2824d3 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -659,4 +659,120 @@ 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. + 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ó diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5ca859e34..dedeccaa5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -658,4 +658,120 @@ 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. + 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index cdd1000d5..e2dd622d0 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -639,4 +639,120 @@ メッセージが大きすぎます レート制限中です。しばらくしてから再試行してください 送信失敗: %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。 + メッセージは既に処理されています。 + 対象メッセージが見つかりませんでした。 + メッセージが長すぎます。 + レート制限されています。しばらくしてからもう一度お試しください。 + 対象ユーザー diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index c11049399..dbeb90520 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -664,4 +664,120 @@ Хабарлама тым үлкен Жылдамдық шектелді, біраз уақыттан кейін қайталаңыз Жіберу сәтсіз: %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. + Хабарлама бұрыннан өңделген. + Мақсатты хабарлама табылмады. + Хабарламаңыз тым ұзын. + Жылдамдығыңыз шектелді. Біраз уақыттан кейін қайталаңыз. + Мақсатты пайдаланушы diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 2e5a59753..71c6e1a58 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -664,4 +664,120 @@ ମେସେଜ୍ ବହୁତ ବଡ଼ ହାର ସୀମିତ, କିଛି ସମୟ ପରେ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ ପଠାଇବା ବିଫଳ: %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) ହେବା ଉଚିତ। + ବିଜ୍ଞାପନ ଚଲାଇବା ପାଇଁ ଆପଣ ଲାଇଭ ଷ୍ଟ୍ରିମିଂ କରୁଥିବା ଆବଶ୍ୟକ। + ଆଉ ଏକ ବିଜ୍ଞାପନ ଚଲାଇବା ପୂର୍ବରୁ ଆପଣଙ୍କ କୁଲ୍-ଡାଉନ୍ ସମୟ ଶେଷ ହେବା ପର୍ଯ୍ୟନ୍ତ ଅପେକ୍ଷା କରନ୍ତୁ। + କମାଣ୍ଡରେ ଶୂନ୍ୟରୁ ଅଧିକ ବିଜ୍ଞାପନ ବ୍ରେକ୍ ଲମ୍ବ ଅନ୍ତର୍ଭୁକ୍ତ ହେବା ଆବଶ୍ୟକ। + ଆପଣଙ୍କର କୌଣସି ସକ୍ରିୟ ରେଡ୍ ନାହିଁ। + ଏକ ଚ୍ୟାନେଲ ନିଜକୁ ରେଡ୍ କରିପାରିବ ନାହିଁ। + ବ୍ରଡ୍‌କାଷ୍ଟର ନିଜକୁ Shoutout ଦେଇପାରିବେ ନାହିଁ। + ବ୍ରଡ୍‌କାଷ୍ଟର ଲାଇଭ ଷ୍ଟ୍ରିମିଂ କରୁନାହାଁନ୍ତି କିମ୍ବା ଏକ ବା ଅଧିକ ଦର୍ଶକ ନାହାଁନ୍ତି। + ସମୟସୀମା ବୈଧ ସୀମାରୁ ବାହାରେ: %1$s। + ମେସେଜ୍ ପୂର୍ବରୁ ପ୍ରକ୍ରିୟାକୃତ ହୋଇସାରିଛି। + ଲକ୍ଷ୍ୟ ମେସେଜ୍ ମିଳିଲା ନାହିଁ। + ଆପଣଙ୍କ ମେସେଜ୍ ବହୁତ ଲମ୍ବା ଥିଲା। + ଆପଣଙ୍କ ହାର ସୀମିତ ହୋଇଛି। କିଛି ସମୟ ପରେ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। + ଲକ୍ଷ୍ୟ ୟୁଜର diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 124eeb83c..5a0b795a2 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -701,4 +701,120 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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. + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3d79b4222..50f8397a1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -670,4 +670,120 @@ 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. + 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d1d40961a..ae1556510 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -660,4 +660,120 @@ 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. + 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index a6b24abac..bc3076e67 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -687,4 +687,120 @@ Сообщение слишком большое Превышен лимит запросов, попробуйте через некоторое время Ошибка отправки: %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$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. + Сообщение уже было обработано. + Целевое сообщение не найдено. + Ваше сообщение было слишком длинным. + Частота ваших запросов ограничена. Попробуйте через мгновение. + Целевой пользователь diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index fee3cb965..54ad17817 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -712,4 +712,120 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Порука је превелика Ограничење брзине, покушајте поново за тренутак Слање неуспешно: %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$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. + Порука је већ обрађена. + Циљна порука није пронађена. + Ваша порука је била предуга. + Ограничена вам је брзина. Покушајте поново за тренутак. + Циљни корисник diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 4ff4f7c4a..a5a5a01ed 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -680,4 +680,120 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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. + 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ı diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 30693e569..9dd2a93cf 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -684,4 +684,120 @@ Повідомлення занадто велике Перевищено ліміт запитів, спробуйте через деякий час Помилка надсилання: %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$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. + Повідомлення вже було оброблено. + Цільове повідомлення не знайдено. + Ваше повідомлення було занадто довгим. + Частоту ваших запитів обмежено. Спробуйте через мить. + Цільовий користувач diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28667032b..ac0e11073 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -754,4 +754,120 @@ 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. + 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 From 00ebc74779734e771cd4a307c2481113c97bb7d8 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 10:12:39 +0200 Subject: [PATCH 198/349] feat(chat): Add colorize nicknames setting for users without a set color --- .../dankchat/preferences/chat/ChatSettings.kt | 1 + .../preferences/chat/ChatSettingsScreen.kt | 9 ++++ .../preferences/chat/ChatSettingsState.kt | 5 ++ .../preferences/chat/ChatSettingsViewModel.kt | 5 ++ .../dankchat/ui/chat/ChatMessageMapper.kt | 50 ++++++++++++++++--- .../main/res/values-b+zh+Hant+TW/strings.xml | 2 + app/src/main/res/values-be-rBY/strings.xml | 2 + app/src/main/res/values-ca/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-de-rDE/strings.xml | 2 + app/src/main/res/values-en-rAU/strings.xml | 2 + app/src/main/res/values-en-rGB/strings.xml | 2 + app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values-es-rES/strings.xml | 2 + app/src/main/res/values-fi-rFI/strings.xml | 2 + app/src/main/res/values-fr-rFR/strings.xml | 2 + app/src/main/res/values-hu-rHU/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ja-rJP/strings.xml | 2 + app/src/main/res/values-kk-rKZ/strings.xml | 2 + app/src/main/res/values-or-rIN/strings.xml | 2 + app/src/main/res/values-pl-rPL/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-pt-rPT/strings.xml | 2 + app/src/main/res/values-ru-rRU/strings.xml | 2 + app/src/main/res/values-sr/strings.xml | 2 + app/src/main/res/values-tr-rTR/strings.xml | 2 + app/src/main/res/values-uk-rUA/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 29 files changed, 112 insertions(+), 6 deletions(-) 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 4860ae4c7..f38244eee 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 @@ -15,6 +15,7 @@ data class ChatSettings( 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, 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 f6b2c1c0b..99b5acb25 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 @@ -135,6 +135,7 @@ private fun ChatSettingsScreen( scrollbackLength = settings.scrollbackLength, showUsernames = settings.showUsernames, userLongClickBehavior = settings.userLongClickBehavior, + colorizeNicknames = settings.colorizeNicknames, showTimedOutMessages = settings.showTimedOutMessages, showTimestamps = settings.showTimestamps, timestampFormat = settings.timestampFormat, @@ -177,6 +178,7 @@ private fun GeneralCategory( scrollbackLength: Int, showUsernames: Boolean, userLongClickBehavior: UserLongClickBehavior, + colorizeNicknames: Boolean, showTimedOutMessages: Boolean, showTimestamps: Boolean, timestampFormat: String, @@ -240,6 +242,13 @@ private fun GeneralCategory( 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), 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 index 1ce391083..4a8703a6d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt @@ -36,6 +36,10 @@ sealed interface ChatSettingsInteraction { val value: UserLongClickBehavior, ) : ChatSettingsInteraction + data class ColorizeNicknames( + val value: Boolean, + ) : ChatSettingsInteraction + data class ShowTimedOutMessages( val value: Boolean, ) : ChatSettingsInteraction @@ -90,6 +94,7 @@ data class ChatSettingsState( val scrollbackLength: Int, val showUsernames: Boolean, val userLongClickBehavior: UserLongClickBehavior, + val colorizeNicknames: Boolean, val showTimedOutMessages: Boolean, val showTimestamps: Boolean, val timestampFormat: String, 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 5eed1280d..4e3f174e7 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 @@ -62,6 +62,10 @@ class ChatSettingsViewModel( 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) } } @@ -125,6 +129,7 @@ private fun ChatSettings.toState() = scrollbackLength = scrollbackLength, showUsernames = showUsernames, userLongClickBehavior = userLongClickBehavior, + colorizeNicknames = colorizeNicknames, showTimedOutMessages = showTimedOutMessages, showTimestamps = showTimestamps, timestampFormat = timestampFormat, 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 index afe556738..31e20dddf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -2,10 +2,12 @@ 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 @@ -336,10 +338,11 @@ class ChatMessageMapper( val displayName = tags["display-name"].orEmpty() val login = tags["login"]?.toUserName() - val rawNameColor = + val ircColor = tags["color"]?.ifBlank { null }?.let(android.graphics.Color::parseColor) ?: login?.let { usersRepository.getCachedUserColor(it) } ?: Message.DEFAULT_COLOR + val rawNameColor = resolveNameColor(null, ircColor, tags["user-id"]?.toUserId(), chatSettings) return ChatMessageUiState.UserNoticeMessageUi( id = id, @@ -555,8 +558,7 @@ class ChatMessageMapper( append(message) } - // Store raw color for normalization at render time (needs Compose theme context) - val rawNameColor = userDisplay?.color ?: color + val rawNameColor = resolveNameColor(userDisplay?.color, color, userId, chatSettings) return ChatMessageUiState.PrivMessageUi( id = id, @@ -686,9 +688,8 @@ class ChatMessageMapper( append(message) } - // Store raw colors for normalization at render time (needs Compose theme context) - val rawSenderColor = userDisplay?.color ?: color - val rawRecipientColor = recipientDisplay?.color ?: recipientColor + val rawSenderColor = resolveNameColor(userDisplay?.color, color, userId, chatSettings) + val rawRecipientColor = resolveNameColor(recipientDisplay?.color, recipientColor, recipientId, chatSettings) return ChatMessageUiState.WhisperMessageUi( id = id, @@ -713,6 +714,18 @@ class ChatMessageMapper( ) } + private fun resolveNameColor( + customColor: Int?, + ircColor: Int, + userId: UserId?, + chatSettings: ChatSettings, + ): Int = when { + customColor != null -> customColor + ircColor != Message.DEFAULT_COLOR -> ircColor + chatSettings.colorizeNicknames && userId != null -> getStableColor(userId) + else -> ircColor + } + data class BackgroundColors( val light: Color, val dark: Color, @@ -845,6 +858,31 @@ class ChatMessageMapper( 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 private val CHECKERED_LIGHT = Color( 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 eba6cce8d..85e7df33f 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 @@ -427,6 +427,8 @@ 使用者長按行為 輕觸開啟用戶資訊;長按提及用戶 輕觸提及用戶;長按開啟用戶資訊 + 為暱稱上色 + 為未設定顏色的用戶隨機分配顏色 強制使用英文 強制文字朗讀使用英文,而非系統預設語言 顯示第三方表情符號 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index fb0de5978..4d8336e11 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -297,6 +297,8 @@ Паводзіны пры доўгім націску на нік карыстальніка Звычайны націск адкрывае ўсплывальнае акно з інфармацыяй пра карыстальніка, доўгае – згадванне Звычайны націск адкрывае згадванне, доўгае – усплывальнае акно з інфармацыяй пра карыстальніка + Каляровыя нікі + Прызначаць выпадковы колер карыстальнікам без усталяванага колеру Пераключыць мову на ангельскую Пераключыць мову сінтэзатара гаворкі з сістэмнага на ангельскую Бачныя пабочныя смайлы diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 4450d15fb..fa28fcbeb 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -302,6 +302,8 @@ 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index fd7692842..dd5593b0e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -304,6 +304,8 @@ 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 9845b6e20..edbc4bef5 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -294,6 +294,8 @@ 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index f4d538fb6..72a0aa29c 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -253,6 +253,8 @@ 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index f48b00ac4..f8cd3a43d 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -253,6 +253,8 @@ 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 89c9c5de0..7384f91aa 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -287,6 +287,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 Force language to English Force TTS voice language to English instead of system default Visible third party emotes diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d1929a41b..3c3ba91cf 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -298,6 +298,8 @@ 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index d4f92bd74..633e8c0d4 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -294,6 +294,8 @@ 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index ef81e0c0f..b5457e2da 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -297,6 +297,8 @@ 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index b5e2824d3..3fdc17afa 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -287,6 +287,8 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dedeccaa5..04e11dd0e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -291,6 +291,8 @@ 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index e2dd622d0..6f4df5dbf 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -281,6 +281,8 @@ ユーザーの長押しの動作 通常のクリックでポップアップを開き、長押しでメンション 通常のクリックでメンション、長押しでポップアップを開く + ニックネームに色を付ける + 色が設定されていないユーザーにランダムな色を割り当てる 強制的に言語を英語にする システムのデフォルトの代わりにTTS音声言語を英語にする サードパーティのエモートの表示 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index dbeb90520..11466b8b8 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -425,6 +425,8 @@ Пайдаланушының ұзақ басу әрекеті Тұрақты басу қалқымалы терезені ашады, ескертулерді ұзақ басыңыз Ескертулерді тұрақты басу, қалқымалы терезені ашу түймешігін ұзақ басыңыз + Лақап аттарды бояу + Түсі орнатылмаған пайдаланушыларға кездейсоқ түс тағайындау Ағылшын тіліне форс тілі Жүйелік жала жабудың орнына TTS дауыс тілін ағылшын тіліне мәжбүрлеу Көрінетін үшінші тарап эмоциясы diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 71c6e1a58..3e5735718 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -425,6 +425,8 @@ ଉପଭୋକ୍ତା ଲମ୍ବା କ୍ଲିକ୍ ଆଚରଣ | ନିୟମିତ କ୍ଲିକ୍ ପପ୍ଅପ୍ ଖୋଲିବ, ଲମ୍ବା କ୍ଲିକ୍ ଉଲ୍ଲେଖ | ନିୟମିତ କ୍ଲିକ୍ ଉଲ୍ଲେଖ, ଲମ୍ବା କ୍ଲିକ୍ ପପ୍ଅପ୍ ଖୋଲିବ | + ଡାକନାମ ରଙ୍ଗ କରନ୍ତୁ + ରଙ୍ଗ ସେଟ୍ ନଥିବା ଉପଭୋକ୍ତାମାନଙ୍କୁ ଏକ ଯାଦୃଚ୍ଛିକ ରଙ୍ଗ ନିର୍ଦ୍ଧାରଣ କରନ୍ତୁ ଇଂରାଜୀକୁ ବାଧ୍ୟ କର | ସିଷ୍ଟମ୍ ଡିଫଲ୍ଟ ପରିବର୍ତ୍ତେ TTS ଭଏସ୍ ଭାଷାକୁ ଇଂରାଜୀକୁ ବାଧ୍ୟ କରନ୍ତୁ | ଦୃଶ୍ୟମାନ ତୃତୀୟ ପକ୍ଷ ଇମୋଟ୍ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 5a0b795a2..589d692cd 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -301,6 +301,8 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 50f8397a1..054e7195d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -292,6 +292,8 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index ae1556510..313b29ef6 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -292,6 +292,8 @@ 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index bc3076e67..233389281 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -302,6 +302,8 @@ Поведение при долгом нажатии на имя пользователя Обычное нажатие открывает всплывающее окно с информацией о пользователе, долгое - открывает упоминания Обычное нажатие открывает упоминания, долгое - открывает всплывающее окно с информацией о пользователе + Окрашивать никнеймы + Назначать случайный цвет пользователям без установленного цвета Переключить язык на английский Переключить язык синтезатора речи с системного на английский Видимые сторонние смайлы diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 54ad17817..38a52d493 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -396,6 +396,8 @@ 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 + Обојите надимке + Додели насумичну боју корисницима без постављене боје Vidljivost emotova nezavisnih servisa Twitch услови коришћења и правила: Прикажи акције чипова diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index a5a5a01ed..436b65370 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -293,6 +293,8 @@ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 9dd2a93cf..c5fea9e99 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -304,6 +304,8 @@ Поведінка при довгому натиску на ім\'я користувача Один натиск відкриває попап, довгий натиск відкриває згадування Один натиск відкриває згадування, довгий натиск відкриває попап + Розфарбовувати псевдоніми + Призначити випадковий колір користувачам без встановленого кольору Зробити англійську мовою синтезатора Використання англійської мови для синтезу мовлення замість мови системи Видимі сторонні смайли diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac0e11073..9aa6fd436 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -493,6 +493,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 From 1376f7212d05b50fa1aa373670b8fececa5f9b1c Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 10:48:12 +0200 Subject: [PATCH 199/349] fix(ui): Add navigation bar padding to channel deletion confirmation sheet --- .../com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index 236b2611e..82fd2517c 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -375,6 +376,7 @@ private fun DeleteChannelConfirmation( modifier = Modifier .fillMaxWidth() + .navigationBarsPadding() .padding(horizontal = 16.dp) .padding(bottom = 16.dp), ) { From a965f5644dd66c351f74f1bc4543795fdd483917 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 11:32:44 +0200 Subject: [PATCH 200/349] style: Disable multiline-expression-wrapping and reformat --- app/build.gradle.kts | 2 + .../com/flxrs/dankchat/DankChatApplication.kt | 45 +- .../com/flxrs/dankchat/data/UserName.kt | 20 +- .../flxrs/dankchat/data/api/ApiException.kt | 11 +- .../flxrs/dankchat/data/api/auth/AuthApi.kt | 7 +- .../dankchat/data/api/auth/AuthApiClient.kt | 28 +- .../data/api/badges/BadgesApiClient.kt | 26 +- .../dankchat/data/api/bttv/BTTVApiClient.kt | 26 +- .../dankchat/data/api/dankchat/DankChatApi.kt | 7 +- .../data/api/dankchat/DankChatApiClient.kt | 26 +- .../data/api/eventapi/EventSubClient.kt | 73 ++- .../data/api/eventapi/EventSubTopic.kt | 145 +++-- .../dankchat/data/api/ffz/FFZApiClient.kt | 26 +- .../flxrs/dankchat/data/api/helix/HelixApi.kt | 514 ++++++++-------- .../dankchat/data/api/helix/HelixApiClient.kt | 510 ++++++++-------- .../recentmessages/RecentMessagesApiClient.kt | 15 +- .../data/api/seventv/SevenTVApiClient.kt | 43 +- .../seventv/eventapi/SevenTVEventApiClient.kt | 19 +- .../seventv/eventapi/dto/SubscribeRequest.kt | 14 +- .../eventapi/dto/UnsubscribeRequest.kt | 14 +- .../dankchat/data/api/supibot/SupibotApi.kt | 7 +- .../data/api/supibot/SupibotApiClient.kt | 39 +- .../dankchat/data/api/upload/UploadClient.kt | 145 +++-- .../flxrs/dankchat/data/auth/AuthDataStore.kt | 7 +- .../dankchat/data/debug/AuthDebugSection.kt | 35 +- .../dankchat/data/debug/BuildDebugSection.kt | 21 +- .../data/debug/ChannelDebugSection.kt | 43 +- .../dankchat/data/debug/EmoteDebugSection.kt | 75 ++- .../dankchat/data/debug/ErrorsDebugSection.kt | 27 +- .../dankchat/data/debug/RulesDebugSection.kt | 39 +- .../dankchat/data/debug/StreamDebugSection.kt | 47 +- .../data/debug/UserStateDebugSection.kt | 21 +- .../data/notification/NotificationService.kt | 48 +- .../data/repo/HighlightsRepository.kt | 82 ++- .../dankchat/data/repo/IgnoresRepository.kt | 130 ++-- .../dankchat/data/repo/RepliesRepository.kt | 11 +- .../data/repo/channel/ChannelRepository.kt | 73 ++- .../dankchat/data/repo/chat/ChatConnector.kt | 13 +- .../data/repo/chat/ChatEventProcessor.kt | 48 +- .../data/repo/chat/ChatMessageRepository.kt | 29 +- .../data/repo/chat/ChatMessageSender.kt | 27 +- .../repo/chat/ChatNotificationRepository.kt | 14 +- .../data/repo/chat/MessageProcessor.kt | 42 +- .../data/repo/chat/RecentMessagesHandler.kt | 155 +++-- .../dankchat/data/repo/chat/UserState.kt | 9 +- .../data/repo/chat/UserStateRepository.kt | 16 +- .../data/repo/command/CommandRepository.kt | 85 ++- .../dankchat/data/repo/data/DataRepository.kt | 257 ++++---- .../data/repo/emote/EmoteRepository.kt | 188 +++--- .../dankchat/data/twitch/badge/BadgeSet.kt | 58 +- .../dankchat/data/twitch/badge/BadgeType.kt | 15 +- .../data/twitch/chat/ChatConnection.kt | 54 +- .../twitch/command/TwitchCommandRepository.kt | 111 ++-- .../data/twitch/emote/ChatMessageEmoteType.kt | 19 +- .../dankchat/data/twitch/emote/EmoteType.kt | 31 +- .../data/twitch/emote/ThirdPartyEmoteType.kt | 9 +- .../dankchat/data/twitch/message/Message.kt | 15 +- .../data/twitch/message/ModerationMessage.kt | 348 ++++++----- .../data/twitch/message/NoticeMessage.kt | 51 +- .../data/twitch/message/PrivMessage.kt | 83 ++- .../dankchat/data/twitch/message/RoomState.kt | 85 ++- .../data/twitch/message/UserDisplay.kt | 9 +- .../data/twitch/message/UserNoticeMessage.kt | 101 ++-- .../data/twitch/message/WhisperMessage.kt | 116 ++-- .../data/twitch/pubsub/PubSubConnection.kt | 23 +- .../data/twitch/pubsub/PubSubManager.kt | 44 +- .../com/flxrs/dankchat/di/CoroutineModule.kt | 13 +- .../com/flxrs/dankchat/di/DatabaseModule.kt | 9 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 179 +++--- .../dankchat/domain/ChannelDataCoordinator.kt | 7 +- .../dankchat/domain/ChannelDataLoader.kt | 82 ++- .../dankchat/domain/GetChannelsUseCase.kt | 53 +- .../flxrs/dankchat/domain/GlobalDataLoader.kt | 42 +- .../preferences/DankChatPreferenceStore.kt | 39 +- .../appearance/AppearanceSettingsScreen.kt | 13 +- .../preferences/chat/ChatSettingsDataStore.kt | 9 +- .../preferences/chat/ChatSettingsViewModel.kt | 174 +++--- .../chat/commands/CommandsViewModel.kt | 9 +- .../chat/userdisplay/UserDisplayItem.kt | 40 +- .../chat/userdisplay/UserDisplayViewModel.kt | 48 +- .../developer/DeveloperSettingsViewModel.kt | 119 ++-- .../NotificationsSettingsViewModel.kt | 15 +- .../notifications/highlights/HighlightItem.kt | 169 +++--- .../highlights/HighlightsViewModel.kt | 184 +++--- .../notifications/ignores/IgnoreItem.kt | 125 ++-- .../notifications/ignores/IgnoresViewModel.kt | 103 ++-- .../preferences/overview/SecretDankerMode.kt | 13 +- .../stream/StreamsSettingsViewModel.kt | 19 +- .../tools/ToolsSettingsViewModel.kt | 46 +- .../tools/tts/TTSUserIgnoreListViewModel.kt | 9 +- .../tools/upload/ImageUploaderViewModel.kt | 26 +- .../tools/upload/RecentUploadsViewModel.kt | 14 +- .../dankchat/ui/changelog/DankChatVersion.kt | 11 +- .../dankchat/ui/chat/ChatMessageMapper.kt | 103 ++-- .../dankchat/ui/chat/ChatMessageUiState.kt | 11 +- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 77 ++- .../ui/chat/emote/EmoteInfoViewModel.kt | 76 ++- .../dankchat/ui/chat/emote/StackedEmote.kt | 19 +- .../dankchat/ui/chat/emotemenu/EmoteItem.kt | 9 +- .../chat/message/MessageOptionsViewModel.kt | 36 +- .../dankchat/ui/chat/search/ChatItemFilter.kt | 75 ++- .../ui/chat/search/ChatSearchFilterParser.kt | 9 +- .../ui/chat/suggestion/SuggestionProvider.kt | 78 ++- .../ui/chat/user/UserPopupViewModel.kt | 96 ++- .../flxrs/dankchat/ui/login/LoginViewModel.kt | 39 +- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 122 ++-- .../flxrs/dankchat/ui/main/MainActivity.kt | 9 +- .../dankchat/ui/main/QuickActionsMenu.kt | 118 ++-- .../channel/ChannelManagementViewModel.kt | 19 +- .../ui/main/dialog/ModActionsDialog.kt | 17 +- .../ui/onboarding/OnboardingDataStore.kt | 13 +- .../dankchat/ui/tour/FeatureTourViewModel.kt | 42 +- .../com/flxrs/dankchat/utils/DateTimeUtils.kt | 73 ++- .../dankchat/utils/GetImageOrVideoContract.kt | 18 +- .../com/flxrs/dankchat/utils/MediaUtils.kt | 26 +- .../com/flxrs/dankchat/utils/TextResource.kt | 45 +- .../utils/compose/BottomSheetNestedScroll.kt | 9 +- .../utils/compose/PredictiveBackModifier.kt | 13 +- .../utils/compose/RoundedCornerPadding.kt | 141 +++-- .../utils/compose/buildLinkAnnotation.kt | 47 +- .../datastore/DataStoreKotlinxSerializer.kt | 7 +- .../utils/datastore/DataStoreUtils.kt | 11 +- .../dankchat/utils/datastore/Migration.kt | 51 +- .../utils/extensions/ChatListOperations.kt | 64 +- .../utils/extensions/CoroutineExtensions.kt | 40 +- .../dankchat/utils/extensions/Extensions.kt | 27 +- .../utils/extensions/FlowExtensions.kt | 52 +- .../utils/extensions/ModerationOperations.kt | 104 ++-- .../utils/extensions/StringExtensions.kt | 9 +- .../extensions/SystemMessageOperations.kt | 9 +- .../twitch/chat/TwitchIrcIntegrationTest.kt | 234 ++++---- .../domain/ChannelDataCoordinatorTest.kt | 272 ++++----- .../dankchat/domain/ChannelDataLoaderTest.kt | 196 +++--- .../ui/tour/FeatureTourViewModelTest.kt | 567 +++++++++--------- 134 files changed, 4299 insertions(+), 4730 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30757a09a..06cd9558f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -246,6 +246,8 @@ spotless { "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", ), ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 32e04f24d..b0f7a5a26 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -78,28 +78,27 @@ class DankChatApplication : } @OptIn(ExperimentalCoilApi::class) - 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}" - } + 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( - httpClient = { client }, - cacheStrategy = { CacheControlCacheStrategy() }, - ) - add(fetcher) - }.build() + } + val fetcher = + KtorNetworkFetcherFactory( + httpClient = { client }, + cacheStrategy = { CacheControlCacheStrategy() }, + ) + add(fetcher) + }.build() } 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 3985f5ca8..08ddd2782 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt @@ -14,17 +14,15 @@ value class UserName( fun lowercase() = UserName(value.lowercase()) - fun formatWithDisplayName(displayName: DisplayName): String = - when { - matches(displayName) -> displayName.value - else -> "$this($displayName)" - } - - fun valueOrDisplayName(displayName: DisplayName): String = - when { - matches(displayName) -> displayName.value - else -> this.value - } + fun formatWithDisplayName(displayName: DisplayName): String = when { + matches(displayName) -> displayName.value + else -> "$this($displayName)" + } + + fun valueOrDisplayName(displayName: DisplayName): String = when { + matches(displayName) -> displayName.value + else -> this.value + } fun matches( other: String, 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 176d5416c..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 @@ -40,13 +40,12 @@ open class ApiException( } } -fun Result.recoverNotFoundWith(default: R): Result = - recoverCatching { - when { - it is ApiException && it.status == HttpStatusCode.NotFound -> default - else -> throw it - } +fun Result.recoverNotFoundWith(default: R): Result = recoverCatching { + when { + it is ApiException && it.status == HttpStatusCode.NotFound -> default + else -> throw it } +} suspend fun HttpResponse.throwApiErrorOnFailure(json: Json): HttpResponse { if (status.isSuccess()) { 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 ff78af8dc..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 @@ -9,10 +9,9 @@ import io.ktor.http.parameters class AuthApi( private val ktorClient: HttpClient, ) { - suspend fun validateUser(token: String) = - ktorClient.get("validate") { - bearerAuth(token) - } + suspend fun validateUser(token: String) = ktorClient.get("validate") { + bearerAuth(token) + } suspend fun revokeToken( token: String, 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 c9c0e67ca..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 @@ -17,28 +17,26 @@ 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() - } + suspend fun validateUser(token: String): Result = runCatching { + val response = authApi.validateUser(token) + when { + response.status.isSuccess() -> { + response.body() + } - else -> { - val error = json.decodeOrNull(response.bodyAsText()) - throw ApiException(status = response.status, response.request.url, error?.message) - } + 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) - } + ): Result = runCatching { + authApi.revokeToken(token, clientId) + } fun validateScopes(scopes: List): Boolean = scopes.containsAll(SCOPES) 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 fd0c07274..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 @@ -13,19 +13,17 @@ class BadgesApiClient( private val badgesApi: BadgesApi, private val json: Json, ) { - suspend fun getChannelBadges(channelId: UserId): Result = - runCatching { - badgesApi - .getChannelBadges(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) + suspend fun getChannelBadges(channelId: UserId): Result = runCatching { + badgesApi + .getChannelBadges(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) - suspend fun getGlobalBadges(): Result = - runCatching { - badgesApi - .getGlobalBadges() - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) + suspend fun getGlobalBadges(): Result = runCatching { + badgesApi + .getGlobalBadges() + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) } 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 d452e61c0..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 @@ -14,19 +14,17 @@ class BTTVApiClient( private val bttvApi: BTTVApi, private val json: Json, ) { - suspend fun getBTTVChannelEmotes(channelId: UserId): Result = - runCatching { - bttvApi - .getChannelEmotes(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(null) + suspend fun getBTTVChannelEmotes(channelId: UserId): Result = runCatching { + bttvApi + .getChannelEmotes(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(null) - suspend fun getBTTVGlobalEmotes(): Result> = - runCatching { - bttvApi - .getGlobalEmotes() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getBTTVGlobalEmotes(): Result> = runCatching { + bttvApi + .getGlobalEmotes() + .throwApiErrorOnFailure(json) + .body() + } } 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 0e04f3baa..944450468 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 @@ -7,10 +7,9 @@ import io.ktor.client.request.parameter class DankChatApi( private val ktorClient: HttpClient, ) { - suspend fun getSets(ids: String) = - ktorClient.get("sets") { - parameter("id", ids) - } + suspend fun getSets(ids: String) = ktorClient.get("sets") { + parameter("id", ids) + } suspend fun getDankChatBadges() = ktorClient.get("badges") } 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 4f5f4e4fa..aaa2ed785 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 @@ -12,19 +12,17 @@ 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() - } + suspend fun getUserSets(sets: List): Result> = runCatching { + dankChatApi + .getSets(sets.joinToString(separator = ",")) + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getDankChatBadges(): Result> = - runCatching { - dankChatApi - .getDankChatBadges() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getDankChatBadges(): Result> = runCatching { + dankChatApi + .getDankChatBadges() + .throwApiErrorOnFailure(json) + .body() + } } 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 abf3c869e..1ba867071 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 @@ -203,48 +203,47 @@ class EventSubClient( } } - suspend fun subscribe(topic: EventSubTopic) = - subscriptionMutex.withLock { - wantedSubscriptions += topic - if (subscriptions.value.any { it.topic == topic }) { - // already subscribed, nothing to do - return@withLock - } - - // 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") - connect() - } + suspend fun subscribe(topic: EventSubTopic) = subscriptionMutex.withLock { + wantedSubscriptions += topic + if (subscriptions.value.any { it.topic == topic }) { + // already subscribed, nothing to do + 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 - } + // 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") + connect() + } - val subscription = response.data.firstOrNull()?.id - if (subscription == null) { - Log.e(TAG, "[EventSub] subscription response did not include subscription id: $response") - 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 + } - Log.d(TAG, "[EventSub] subscribed to $topic") - emitSystemMessage(message = "[EventSub] subscribed to ${topic.shortFormatted()}") - subscriptions.update { it + SubscribedTopic(subscription, topic) } + val subscription = response.data.firstOrNull()?.id + if (subscription == null) { + Log.e(TAG, "[EventSub] subscription response did not include subscription id: $response") + return@withLock } + Log.d(TAG, "[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 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 03f49e03f..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 @@ -19,21 +19,20 @@ sealed interface EventSubTopic { val broadcasterId: UserId, val moderatorId: UserId, ) : 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, - ), - ) + override fun createRequest(sessionId: String) = EventSubSubscriptionRequestDto( + type = EventSubSubscriptionType.ChannelModerate, + version = "2", + condition = + EventSubModeratorConditionDto( + broadcasterUserId = broadcasterId, + moderatorUserId = moderatorId, + ), + transport = + EventSubTransportDto( + sessionId = sessionId, + method = EventSubMethod.Websocket, + ), + ) override fun shortFormatted(): String = "ChannelModerate($channel)" } @@ -43,21 +42,20 @@ sealed interface EventSubTopic { 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 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)" } @@ -67,21 +65,20 @@ sealed interface EventSubTopic { 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 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)" } @@ -91,21 +88,20 @@ sealed interface EventSubTopic { 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 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)" } @@ -115,21 +111,20 @@ sealed interface EventSubTopic { 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 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)" } 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 93509150b..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 @@ -14,19 +14,17 @@ class FFZApiClient( private val ffzApi: FFZApi, private val json: Json, ) { - suspend fun getFFZChannelEmotes(channelId: UserId): Result = - runCatching { - ffzApi - .getChannelEmotes(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(null) + suspend fun getFFZChannelEmotes(channelId: UserId): Result = runCatching { + ffzApi + .getChannelEmotes(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(null) - suspend fun getFFZGlobalEmotes(): Result = - runCatching { - ffzApi - .getGlobalEmotes() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getFFZGlobalEmotes(): Result = runCatching { + ffzApi + .getGlobalEmotes() + .throwApiErrorOnFailure(json) + .body() + } } 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 69f89a81e..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 @@ -38,396 +38,360 @@ class HelixApi( return authDataStore.oAuthKey?.withoutOAuthPrefix } - suspend fun getUsersByName(logins: List): HttpResponse? = - ktorClient.get("users") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - logins.forEach { - parameter("login", it) - } + suspend fun getUsersByName(logins: List): HttpResponse? = ktorClient.get("users") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + logins.forEach { + parameter("login", it) } + } - suspend fun getUsersByIds(ids: List): HttpResponse? = - ktorClient.get("users") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - ids.forEach { - parameter("id", it) - } + suspend fun getUsersByIds(ids: List): HttpResponse? = ktorClient.get("users") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - if (targetUserId != null) { - parameter("user_id", targetUserId) - } - if (first != null) { - parameter("first", first) - } - if (after != null) { - parameter("after", after) - } + ): HttpResponse? = ktorClient.get("channels/followers") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + if (targetUserId != null) { + parameter("user_id", targetUserId) + } + if (first != null) { + parameter("first", first) } + if (after != null) { + parameter("after", after) + } + } - suspend fun getStreams(channels: List): HttpResponse? = - ktorClient.get("streams") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - channels.forEach { - parameter("user_login", it) - } + suspend fun getStreams(channels: List): HttpResponse? = ktorClient.get("streams") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", userId) - parameter("first", first) - if (after != null) { - parameter("after", after) - } + ): HttpResponse? = ktorClient.get("users/blocks") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", userId) + parameter("first", first) + if (after != null) { + parameter("after", after) } + } - suspend fun putUserBlock(targetUserId: UserId): HttpResponse? = - ktorClient.put("users/blocks") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("target_user_id", targetUserId) - } + suspend fun putUserBlock(targetUserId: UserId): HttpResponse? = ktorClient.put("users/blocks") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("target_user_id", targetUserId) + } - suspend fun deleteUserBlock(targetUserId: UserId): HttpResponse? = - ktorClient.delete("users/blocks") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("target_user_id", targetUserId) - } + suspend fun deleteUserBlock(targetUserId: UserId): HttpResponse? = ktorClient.delete("users/blocks") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + ): HttpResponse? = ktorClient.post("chat/announcements") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("moderator_id", moderatorUserId) + contentType(ContentType.Application.Json) + setBody(request) + } 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) - if (after != null) { - parameter("after", after) - } + ): HttpResponse? = ktorClient.get("moderation/moderators") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("first", first) + if (after != null) { + parameter("after", after) } + } 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) - } + ): 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("user_id", 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("from_user_id", fromUserId) - parameter("to_user_id", toUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + ): HttpResponse? = ktorClient.post("whispers") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("from_user_id", fromUserId) + parameter("to_user_id", toUserId) + contentType(ContentType.Application.Json) + setBody(request) + } 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) - if (after != null) { - parameter("after", after) - } + ): HttpResponse? = ktorClient.get("channels/vips") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("first", first) + if (after != null) { + parameter("after", after) } + } 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) - } + ): 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("user_id", 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + ): HttpResponse? = ktorClient.post("moderation/bans") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("moderator_id", moderatorUserId) + contentType(ContentType.Application.Json) + setBody(request) + } 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) - } + ): 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - if (messageId != null) { - parameter("message_id", messageId) - } + ): HttpResponse? = ktorClient.delete("moderation/chat") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("moderator_id", moderatorUserId) + if (messageId != null) { + parameter("message_id", messageId) } + } 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) - } + ): 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 = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - setBody(request) - } + suspend fun postMarker(request: MarkerRequestDto): HttpResponse? = ktorClient.post("streams/markers") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - setBody(request) - } + suspend fun postCommercial(request: CommercialRequestDto): HttpResponse? = ktorClient.post("channels/commercial") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("from_broadcaster_id", broadcasterUserId) - parameter("to_broadcaster_id", targetUserId) - } + ): 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - } + suspend fun deleteRaid(broadcasterUserId: UserId): HttpResponse? = ktorClient.delete("raids") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - setBody(request) - } + ): HttpResponse? = ktorClient.patch("chat/settings") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("moderator_id", moderatorUserId) + contentType(ContentType.Application.Json) + setBody(request) + } - suspend fun getGlobalBadges(): HttpResponse? = - ktorClient.get("chat/badges/global") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - } + suspend fun getGlobalBadges(): HttpResponse? = ktorClient.get("chat/badges/global") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + contentType(ContentType.Application.Json) + } - suspend fun getChannelBadges(broadcasterUserId: UserId): HttpResponse? = - ktorClient.get("chat/badges") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("broadcaster_id", broadcasterUserId) - contentType(ContentType.Application.Json) - } + suspend fun getChannelBadges(broadcasterUserId: UserId): HttpResponse? = ktorClient.get("chat/badges") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + contentType(ContentType.Application.Json) + } - 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 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 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) - parameter("moderator_id", moderatorUserId) - contentType(ContentType.Application.Json) - } + ): HttpResponse? = ktorClient.post("chat/shoutouts") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("from_broadcaster_id", broadcasterUserId) + parameter("to_broadcaster_id", targetUserId) + parameter("moderator_id", moderatorUserId) + contentType(ContentType.Application.Json) + } 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) - } + ): 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) - contentType(ContentType.Application.Json) - setBody(request) - } + ): HttpResponse? = ktorClient.put("moderation/shield_mode") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("moderator_id", moderatorUserId) + contentType(ContentType.Application.Json) + setBody(request) + } - suspend fun postEventSubSubscription(eventSubSubscriptionRequestDto: EventSubSubscriptionRequestDto): HttpResponse? = - ktorClient.post("eventsub/subscriptions") { - val oAuth = getValidToken() ?: return null - bearerAuth(oAuth) - contentType(ContentType.Application.Json) - setBody(eventSubSubscriptionRequestDto) - } + suspend fun postEventSubSubscription(eventSubSubscriptionRequestDto: EventSubSubscriptionRequestDto): HttpResponse? = ktorClient.post("eventsub/subscriptions") { + 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 = getValidToken() ?: return null - bearerAuth(oAuth) - parameter("id", id) - } + suspend fun deleteEventSubSubscription(id: String): HttpResponse? = ktorClient.delete("eventsub/subscriptions") { + 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) - } + ): 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 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) - } + 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 31ca3a2c9..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 @@ -48,402 +48,362 @@ 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) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + suspend fun getUsersByNames(names: List): Result> = runCatching { + names.chunked(DEFAULT_PAGE_SIZE).flatMap { + helixApi + .getUsersByName(it) + .throwHelixApiErrorOnFailure() + .body>() + .data } + } - suspend fun getUsersByIds(ids: List): Result> = - runCatching { - ids.chunked(DEFAULT_PAGE_SIZE).flatMap { - helixApi - .getUsersByIds(it) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + suspend fun getUsersByIds(ids: List): Result> = runCatching { + ids.chunked(DEFAULT_PAGE_SIZE).flatMap { + helixApi + .getUsersByIds(it) + .throwHelixApiErrorOnFailure() + .body>() + .data } + } - suspend fun getUser(userId: UserId): Result = - getUsersByIds(listOf(userId)) - .mapCatching { it.first() } + suspend fun getUser(userId: UserId): Result = getUsersByIds(listOf(userId)) + .mapCatching { it.first() } - suspend fun getUserByName(name: UserName): Result = - getUsersByNames(listOf(name)) - .mapCatching { it.first() } + suspend fun getUserByName(name: UserName): Result = getUsersByNames(listOf(name)) + .mapCatching { it.first() } - suspend fun getUserIdByName(name: UserName): Result = - getUserByName(name) - .mapCatching { it.id } + suspend fun getUserIdByName(name: UserName): Result = getUserByName(name) + .mapCatching { it.id } suspend fun getChannelFollowers( broadcastUserId: UserId, targetUserId: UserId, - ): Result = - runCatching { + ): Result = runCatching { + helixApi + .getChannelFollowers(broadcastUserId, targetUserId) + .throwHelixApiErrorOnFailure() + .body() + } + + suspend fun getStreams(channels: List): Result> = runCatching { + channels.chunked(DEFAULT_PAGE_SIZE).flatMap { helixApi - .getChannelFollowers(broadcastUserId, targetUserId) + .getStreams(it) .throwHelixApiErrorOnFailure() - .body() - } - - suspend fun getStreams(channels: List): Result> = - runCatching { - channels.chunked(DEFAULT_PAGE_SIZE).flatMap { - helixApi - .getStreams(it) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + .body>() + .data } + } suspend fun getUserBlocks( userId: UserId, maxUserBlocksToFetch: Int = 500, - ): Result> = - runCatching { - pageUntil(maxUserBlocksToFetch) { cursor -> - helixApi.getUserBlocks(userId, DEFAULT_PAGE_SIZE, cursor) - } + ): Result> = runCatching { + pageUntil(maxUserBlocksToFetch) { cursor -> + helixApi.getUserBlocks(userId, DEFAULT_PAGE_SIZE, cursor) } + } - suspend fun blockUser(targetUserId: UserId): Result = - runCatching { - helixApi - .putUserBlock(targetUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun blockUser(targetUserId: UserId): Result = runCatching { + helixApi + .putUserBlock(targetUserId) + .throwHelixApiErrorOnFailure() + } - suspend fun unblockUser(targetUserId: UserId): Result = - runCatching { - helixApi - .deleteUserBlock(targetUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun unblockUser(targetUserId: UserId): Result = runCatching { + helixApi + .deleteUserBlock(targetUserId) + .throwHelixApiErrorOnFailure() + } suspend fun postAnnouncement( broadcastUserId: UserId, moderatorUserId: UserId, request: AnnouncementRequestDto, - ): Result = - runCatching { - helixApi - .postAnnouncement(broadcastUserId, moderatorUserId, request) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .postAnnouncement(broadcastUserId, moderatorUserId, request) + .throwHelixApiErrorOnFailure() + } suspend fun postWhisper( fromUserId: UserId, toUserId: UserId, request: WhisperRequestDto, - ): Result = - runCatching { - helixApi - .postWhisper(fromUserId, toUserId, request) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .postWhisper(fromUserId, toUserId, request) + .throwHelixApiErrorOnFailure() + } suspend fun getModerators( broadcastUserId: UserId, maxModeratorsToFetch: Int = 500, - ): Result> = - runCatching { - pageUntil(maxModeratorsToFetch) { cursor -> - helixApi.getModerators(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) - } + ): 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) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .postModerator(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() + } suspend fun deleteModerator( broadcastUserId: UserId, userId: UserId, - ): Result = - runCatching { - helixApi - .deleteModerator(broadcastUserId, userId) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .deleteModerator(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() + } suspend fun getVips( broadcastUserId: UserId, maxVipsToFetch: Int = 500, - ): Result> = - runCatching { - pageUntil(maxVipsToFetch) { cursor -> - helixApi.getVips(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) - } + ): 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) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .postVip(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() + } suspend fun deleteVip( broadcastUserId: UserId, userId: UserId, - ): Result = - runCatching { - helixApi - .deleteVip(broadcastUserId, userId) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .deleteVip(broadcastUserId, userId) + .throwHelixApiErrorOnFailure() + } suspend fun postBan( broadcastUserId: UserId, moderatorUserId: UserId, requestDto: BanRequestDto, - ): Result = - runCatching { - helixApi - .postBan(broadcastUserId, moderatorUserId, requestDto) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .postBan(broadcastUserId, moderatorUserId, requestDto) + .throwHelixApiErrorOnFailure() + } suspend fun deleteBan( broadcastUserId: UserId, moderatorUserId: UserId, targetUserId: UserId, - ): Result = - runCatching { - helixApi - .deleteBan(broadcastUserId, moderatorUserId, targetUserId) - .throwHelixApiErrorOnFailure() - } + ): 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) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .deleteMessages(broadcastUserId, moderatorUserId, messageId) + .throwHelixApiErrorOnFailure() + } suspend fun putUserChatColor( targetUserId: UserId, color: String, - ): Result = - runCatching { - helixApi - .putUserChatColor(targetUserId, color) - .throwHelixApiErrorOnFailure() - } + ): Result = runCatching { + helixApi + .putUserChatColor(targetUserId, color) + .throwHelixApiErrorOnFailure() + } - suspend fun postMarker(requestDto: MarkerRequestDto): Result = - runCatching { - helixApi - .postMarker(requestDto) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun postMarker(requestDto: MarkerRequestDto): Result = runCatching { + helixApi + .postMarker(requestDto) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun postCommercial(request: CommercialRequestDto): Result = - runCatching { - helixApi - .postCommercial(request) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun postCommercial(request: CommercialRequestDto): Result = runCatching { + helixApi + .postCommercial(request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } suspend fun postRaid( broadcastUserId: UserId, targetUserId: UserId, - ): Result = - runCatching { - helixApi - .postRaid(broadcastUserId, targetUserId) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + ): Result = runCatching { + helixApi + .postRaid(broadcastUserId, targetUserId) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun deleteRaid(broadcastUserId: UserId): Result = - runCatching { - helixApi - .deleteRaid(broadcastUserId) - .throwHelixApiErrorOnFailure() - } + suspend fun deleteRaid(broadcastUserId: UserId): Result = runCatching { + helixApi + .deleteRaid(broadcastUserId) + .throwHelixApiErrorOnFailure() + } suspend fun patchChatSettings( broadcastUserId: UserId, moderatorUserId: UserId, request: ChatSettingsRequestDto, - ): Result = - runCatching { - helixApi - .patchChatSettings(broadcastUserId, moderatorUserId, request) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + ): Result = runCatching { + helixApi + .patchChatSettings(broadcastUserId, moderatorUserId, request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun getGlobalBadges(): Result> = - runCatching { - helixApi - .getGlobalBadges() - .throwHelixApiErrorOnFailure() - .body>() - .data - } + suspend fun getGlobalBadges(): Result> = runCatching { + helixApi + .getGlobalBadges() + .throwHelixApiErrorOnFailure() + .body>() + .data + } - suspend fun getChannelBadges(broadcastUserId: UserId): Result> = - runCatching { - helixApi - .getChannelBadges(broadcastUserId) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + suspend fun getChannelBadges(broadcastUserId: UserId): Result> = runCatching { + helixApi + .getChannelBadges(broadcastUserId) + .throwHelixApiErrorOnFailure() + .body>() + .data + } - suspend fun getCheermotes(broadcasterId: UserId): Result> = - runCatching { - helixApi - .getCheermotes(broadcasterId) - .throwHelixApiErrorOnFailure() - .body>() - .data - } + 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() - } + ): Result = runCatching { + helixApi + .postManageAutomodMessage(ManageAutomodMessageRequestDto(userId = userId, msgId = msgId, action = action)) + .throwHelixApiErrorOnFailure() + } suspend fun postShoutout( broadcastUserId: UserId, targetUserId: UserId, moderatorUserId: UserId, - ): Result = - runCatching { - helixApi - .postShoutout(broadcastUserId, targetUserId, moderatorUserId) - .throwHelixApiErrorOnFailure() - } + ): 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() - } + ): 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 - .first() - } + ): Result = runCatching { + helixApi + .putShieldMode(broadcastUserId, moderatorUserId, request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } - suspend fun postEventSubSubscription(request: EventSubSubscriptionRequestDto): Result = - runCatching { - helixApi - .postEventSubSubscription(request) - .throwHelixApiErrorOnFailure() - .body() - } + suspend fun postEventSubSubscription(request: EventSubSubscriptionRequestDto): Result = runCatching { + helixApi + .postEventSubSubscription(request) + .throwHelixApiErrorOnFailure() + .body() + } - suspend fun deleteEventSubSubscription(id: String): Result = - runCatching { - helixApi - .deleteEventSubSubscription(id) - .throwHelixApiErrorOnFailure() - } + suspend fun deleteEventSubSubscription(id: String): Result = runCatching { + helixApi + .deleteEventSubSubscription(id) + .throwHelixApiErrorOnFailure() + } - fun getUserEmotesFlow(userId: UserId): Flow> = - pageAsFlow(MAX_USER_EMOTES) { cursor -> - helixApi.getUserEmotes(userId, cursor) - } + 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 getChannelEmotes(broadcasterId: UserId): Result> = runCatching { + helixApi + .getChannelEmotes(broadcasterId) + .throwHelixApiErrorOnFailure() + .body>() + .data + } - suspend fun postChatMessage(request: SendChatMessageRequestDto): Result = - runCatching { - helixApi - .postChatMessage(request) - .throwHelixApiErrorOnFailure() - .body>() - .data - .first() - } + suspend fun postChatMessage(request: SendChatMessageRequestDto): Result = runCatching { + helixApi + .postChatMessage(request) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } private inline fun pageAsFlow( amountToFetch: Int, crossinline request: suspend (cursor: String?) -> HttpResponse?, - ): Flow> = - flow { - val initialPage = - request(null) + ): Flow> = flow { + val initialPage = + request(null) + .throwHelixApiErrorOnFailure() + .body>() + emit(initialPage.data) + var cursor = initialPage.pagination.cursor + var count = initialPage.data.size + while (cursor != null && count < amountToFetch) { + val result = + request(cursor) .throwHelixApiErrorOnFailure() .body>() - emit(initialPage.data) - var cursor = initialPage.pagination.cursor - 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 - } + emit(result.data) + count += result.data.size + cursor = result.pagination.cursor } + } private suspend inline fun pageUntil( amountToFetch: Int, 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 2c41f5a81..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 @@ -19,14 +19,13 @@ class RecentMessagesApiClient( suspend fun getRecentMessages( channel: UserName, messageLimit: Int? = null, - ): Result = - runCatching { - val limit = messageLimit ?: chatSettingsDataStore.settings.first().scrollbackLength - recentMessagesApi - .getRecentMessages(channel, limit) - .throwRecentMessagesErrorOnFailure() - .body() - } + ): Result = runCatching { + val limit = messageLimit ?: chatSettingsDataStore.settings.first().scrollbackLength + recentMessagesApi + .getRecentMessages(channel, limit) + .throwRecentMessagesErrorOnFailure() + .body() + } private suspend fun HttpResponse.throwRecentMessagesErrorOnFailure(): HttpResponse { if (status.isSuccess()) { 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 6ea6c6b43..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 @@ -15,29 +15,26 @@ class SevenTVApiClient( private val sevenTVApi: SevenTVApi, private val json: Json, ) { - suspend fun getSevenTVChannelEmotes(channelId: UserId): Result = - runCatching { - sevenTVApi - .getChannelEmotes(channelId) - .throwApiErrorOnFailure(json) - .body() - }.recoverNotFoundWith(default = null) + suspend fun getSevenTVChannelEmotes(channelId: UserId): Result = runCatching { + sevenTVApi + .getChannelEmotes(channelId) + .throwApiErrorOnFailure(json) + .body() + }.recoverNotFoundWith(default = null) - suspend fun getSevenTVEmoteSet(emoteSetId: String): Result = - runCatching { - sevenTVApi - .getEmoteSet(emoteSetId) - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getSevenTVEmoteSet(emoteSetId: String): Result = runCatching { + sevenTVApi + .getEmoteSet(emoteSetId) + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getSevenTVGlobalEmotes(): Result> = - runCatching { - sevenTVApi - .getGlobalEmotes() - .throwApiErrorOnFailure(json) - .body() - .emotes - .orEmpty() - } + suspend fun getSevenTVGlobalEmotes(): Result> = runCatching { + sevenTVApi + .getGlobalEmotes() + .throwApiErrorOnFailure(json) + .body() + .emotes + .orEmpty() + } } 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 461f0b82c..4de6308ad 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 @@ -211,18 +211,17 @@ class SevenTVEventApiClient( } } - private fun setupHeartBeatInterval(): Job = - scope.launch { - delay(heartBeatInterval) - timer(heartBeatInterval) { - val webSocket = socket - if (webSocket == null || System.currentTimeMillis() - lastHeartBeat > 3 * heartBeatInterval.inWholeMilliseconds) { - cancel() - reconnect() - return@timer - } + private fun setupHeartBeatInterval(): Job = scope.launch { + delay(heartBeatInterval) + timer(heartBeatInterval) { + val webSocket = socket + if (webSocket == null || System.currentTimeMillis() - lastHeartBeat > 3 * heartBeatInterval.inWholeMilliseconds) { + cancel() + reconnect() + return@timer } } + } private inner class EventApiWebSocketListener : WebSocketListener() { private var heartBeatJob: Job? = null 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 99d5b2562..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 @@ -11,15 +11,13 @@ data class SubscribeRequest( override val d: SubscriptionData, ) : DataRequest { companion object { - fun userUpdates(userId: String) = - SubscribeRequest( - d = SubscriptionData(type = UserUpdates.type, condition = SubscriptionCondition(objectId = userId)), - ) + fun userUpdates(userId: String) = SubscribeRequest( + d = SubscriptionData(type = UserUpdates.type, condition = SubscriptionCondition(objectId = userId)), + ) - fun emoteSetUpdates(emoteSetId: String) = - SubscribeRequest( - d = SubscriptionData(type = EmoteSetUpdates.type, condition = SubscriptionCondition(objectId = emoteSetId)), - ) + fun emoteSetUpdates(emoteSetId: String) = SubscribeRequest( + d = SubscriptionData(type = EmoteSetUpdates.type, condition = SubscriptionCondition(objectId = emoteSetId)), + ) } } 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 7bf3b8833..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 @@ -8,14 +8,12 @@ data class UnsubscribeRequest( override val d: SubscriptionData, ) : DataRequest { companion object { - fun userUpdates(userId: String) = - UnsubscribeRequest( - d = SubscriptionData(type = SubscriptionType.UserUpdates.type, condition = SubscriptionCondition(objectId = userId)), - ) + fun userUpdates(userId: String) = UnsubscribeRequest( + 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)), - ) + fun emoteSetUpdates(emoteSetId: String) = UnsubscribeRequest( + 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 c489d5f57..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 @@ -8,10 +8,9 @@ import io.ktor.client.request.parameter class SupibotApi( private val ktorClient: HttpClient, ) { - suspend fun getChannels(platformName: String = "twitch") = - ktorClient.get("bot/channel/list") { - parameter("platformName", platformName) - } + suspend fun getChannels(platformName: String = "twitch") = ktorClient.get("bot/channel/list") { + parameter("platformName", platformName) + } suspend fun getCommands() = ktorClient.get("bot/command/list/") 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 6baa5c9c8..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 @@ -14,27 +14,24 @@ class SupibotApiClient( private val supibotApi: SupibotApi, private val json: Json, ) { - suspend fun getSupibotCommands(): Result = - runCatching { - supibotApi - .getCommands() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getSupibotCommands(): Result = runCatching { + supibotApi + .getCommands() + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getSupibotChannels(): Result = - runCatching { - supibotApi - .getChannels() - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getSupibotChannels(): Result = runCatching { + supibotApi + .getChannels() + .throwApiErrorOnFailure(json) + .body() + } - suspend fun getSupibotUserAliases(user: UserName): Result = - runCatching { - supibotApi - .getUserAliases(user) - .throwApiErrorOnFailure(json) - .body() - } + suspend fun getSupibotUserAliases(user: UserName): Result = runCatching { + supibotApi + .getUserAliases(user) + .throwApiErrorOnFailure(json) + .body() + } } 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 a2cbd8876..e42002069 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 @@ -30,95 +30,92 @@ class UploadClient( @Named(type = UploadOkHttpClient::class) private val httpClient: OkHttpClient, private val toolsSettingsDataStore: ToolsSettingsDataStore, ) { - suspend fun uploadMedia(file: File): Result = - withContext(Dispatchers.IO) { - val uploader = toolsSettingsDataStore.settings.first().uploaderConfig - val mimetype = URLConnection.guessContentTypeFromName(file.name) + suspend fun uploadMedia(file: File): Result = withContext(Dispatchers.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) - } - }.post(requestBody) - .build() + 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) - } + val response = + runCatching { + httpClient.newCall(request).execute() + }.getOrElse { + return@withContext Result.failure(it) + } - when { - response.isSuccessful -> { - val imageLinkPattern = uploader.imageLinkPattern - val deletionLinkPattern = uploader.deletionLinkPattern + when { + response.isSuccessful -> { + val imageLinkPattern = uploader.imageLinkPattern + val deletionLinkPattern = uploader.deletionLinkPattern - if (imageLinkPattern == null || imageLinkPattern.isBlank()) { - return@withContext runCatching { - val body = response.body.string() - UploadDto( - imageLink = body, - deleteLink = null, - timestamp = Instant.now(), - ) - } + if (imageLinkPattern == null || imageLinkPattern.isBlank()) { + return@withContext runCatching { + val body = response.body.string() + UploadDto( + imageLink = body, + deleteLink = null, + timestamp = Instant.now(), + ) } - - response - .asJsonObject() - .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(), - ) - } } - else -> { - Log.e(TAG, "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)) - } + response + .asJsonObject() + .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(), + ) + } + } + + else -> { + Log.e(TAG, "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)) } } + } @Suppress("RegExpRedundantEscape") - private suspend fun JSONObject.extractLink(linkPattern: String): String = - withContext(Dispatchers.Default) { - var imageLink: String = linkPattern + 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) - } + val regex = "\\{(.+?)\\}".toRegex() + regex.findAll(linkPattern).forEach { + val jsonValue = getValue(it.groupValues[1]) + if (jsonValue != null) { + imageLink = imageLink.replace(it.groupValues[0], jsonValue) } - imageLink } + imageLink + } - private fun Response.asJsonObject(): Result = - runCatching { - val bodyString = body.string() - JSONObject(bodyString) - }.onFailure { - Log.d(TAG, "Error creating JsonObject from response: ", it) - } + private fun Response.asJsonObject(): Result = runCatching { + val bodyString = body.string() + JSONObject(bodyString) + }.onFailure { + Log.d(TAG, "Error creating JsonObject from response: ", it) + } private fun JSONObject.getValue(pattern: String): String? { return runCatching { 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 index c7883ceee..7d9ba5bfa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt @@ -35,10 +35,9 @@ class AuthDataStore( 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 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) 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 index 3b437b7e8..9962e5be6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt @@ -13,22 +13,21 @@ class AuthDebugSection( 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), - ), - ) - } + 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 index 5ead5f81c..cfeca0674 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt @@ -10,15 +10,14 @@ 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), - ), - ), - ) + 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 index 177d84d07..10c5ee468 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt @@ -18,33 +18,32 @@ class ChannelDebugSection( 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")))) - } + 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 -> { + 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" })) - } + else -> { + val display = roomState.toDebugText() + add(DebugEntry("Room state", display.ifEmpty { "None" })) } } - DebugSectionSnapshot(title = "$baseTitle (${channel.value})", entries = entries) - } + } + DebugSectionSnapshot(title = "$baseTitle (${channel.value})", entries = entries) } } } + } } 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 index e664d6846..4404dfd72 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt @@ -19,45 +19,44 @@ class EmoteDebugSection( 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}")), - ) - } + 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}"), - ), - ) - } + 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 index 67aa85de7..16f04c940 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt @@ -14,19 +14,18 @@ class ErrorsDebugSection( 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")) - } + 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")) } - DebugSectionSnapshot(title = baseTitle, entries = entries) - } + 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 index acd1db724..edd904ec6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt @@ -14,24 +14,23 @@ class RulesDebugSection( 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}"), - ), - ) - } + 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/StreamDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt index 4e2f6ffce..291d4de55 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt @@ -15,31 +15,30 @@ class StreamDebugSection( 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")), - ) - } + 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}"), - ), - ) - } + 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 index 4d7beebc8..07676ce69 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt @@ -12,15 +12,14 @@ class UserStateDebugSection( 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" }), - ), - ) - } + 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/notification/NotificationService.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt index 54d24c06f..1b3f19d66 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 @@ -154,11 +154,10 @@ class NotificationService : shouldNotifyOnMention = true } - private suspend fun setTTSEnabled(enabled: Boolean) = - when { - enabled -> initTTS() - else -> shutdownTTS() - } + private suspend fun setTTSEnabled(enabled: Boolean) = when { + enabled -> initTTS() + else -> shutdownTTS() + } private suspend fun initTTS() { val forceEnglish = toolsSettingsDataStore.settings.first().ttsForceEnglish @@ -320,33 +319,30 @@ class NotificationService : 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) - } + private fun String.filterEmotes(emotes: List): String = when { + toolSettings.ttsIgnoreEmotes -> { + emotes.fold(this) { acc, emote -> + acc.replace(emote.code, newValue = "", ignoreCase = true) } + } - else -> { - this - } + 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 = "") + 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 - } + else -> this + } - private fun String.filterUrls(): String = - when { - toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") - else -> this - } + private fun String.filterUrls(): String = when { + toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") + else -> this + } private fun NotificationData.createMentionNotification() { val pendingStartActivityIntent = 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 d61488c1d..aa74a5733 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 @@ -83,40 +83,38 @@ class HighlightsRepository( .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 - } + 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 { + fun runMigrationsIfNeeded() = coroutineScope.launch { + runCatching { + if (messageHighlightDao.getMessageHighlights().isEmpty()) { + Log.d(TAG, "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.") + } + if (badgeHighlightDao.getBadgeHighlights().isEmpty()) { + Log.d(TAG, "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.") + } + }.getOrElse { + Log.e(TAG, "Failed to run highlights migration", it) runCatching { - if (messageHighlightDao.getMessageHighlights().isEmpty()) { - Log.d(TAG, "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.") - } - if (badgeHighlightDao.getBadgeHighlights().isEmpty()) { - Log.d(TAG, "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.") - } - }.getOrElse { - Log.e(TAG, "Failed to run highlights migration", it) - runCatching { - messageHighlightDao.deleteAllHighlights() - userHighlightDao.deleteAllHighlights() - badgeHighlightDao.deleteAllHighlights() - return@launch - } + messageHighlightDao.deleteAllHighlights() + userHighlightDao.deleteAllHighlights() + badgeHighlightDao.deleteAllHighlights() + return@launch } } + } suspend fun addMessageHighlight(): MessageHighlightEntity { val entity = @@ -337,11 +335,10 @@ class HighlightsRepository( return copy(highlights = highlights) } - private suspend fun WhisperMessage.calculateHighlightState(): WhisperMessage = - when { - notificationsSettingsDataStore.settings.first().showWhisperNotifications -> copy(highlights = setOf(Highlight(HighlightType.Notification))) - else -> this - } + private suspend fun WhisperMessage.calculateHighlightState(): WhisperMessage = when { + notificationsSettingsDataStore.settings.first().showWhisperNotifications -> copy(highlights = setOf(Highlight(HighlightType.Notification))) + else -> this + } private fun List.ofType(type: MessageHighlightEntityType): MessageHighlightEntity? = find { it.type == type } @@ -393,14 +390,13 @@ class HighlightsRepository( return false } - private fun List.addDefaultsIfNecessary(): List = - (this + DEFAULT_MESSAGE_HIGHLIGHTS) - .distinctBy { - when (it.type) { - MessageHighlightEntityType.Custom -> it.id - else -> it.type - } - }.sortedBy { it.type.ordinal } + private fun List.addDefaultsIfNecessary(): List = (this + DEFAULT_MESSAGE_HIGHLIGHTS) + .distinctBy { + when (it.type) { + MessageHighlightEntityType.Custom -> it.id + else -> it.type + } + }.sortedBy { it.type.ordinal } companion object { private val TAG = HighlightsRepository::class.java.simpleName 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 3fe453732..815e3c510 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 @@ -65,71 +65,68 @@ class IgnoresRepository( .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 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 { - runCatching { - if (messageIgnoreDao.getMessageIgnores().isNotEmpty()) { - return@launch - } + fun runMigrationsIfNeeded() = coroutineScope.launch { + runCatching { + if (messageIgnoreDao.getMessageIgnores().isNotEmpty()) { + return@launch + } - Log.d(TAG, "Running ignores migration...") - messageIgnoreDao.addIgnores(DEFAULT_IGNORES) - - val totalIgnores = DEFAULT_IGNORES.size - Log.d(TAG, "Ignores migration completed, added $totalIgnores entries.") - }.getOrElse { - Log.e(TAG, "Failed to run ignores migration", it) - runCatching { - messageIgnoreDao.deleteAllIgnores() - userIgnoreDao.deleteAllIgnores() - return@launch - } + Log.d(TAG, "Running ignores migration...") + messageIgnoreDao.addIgnores(DEFAULT_IGNORES) + + val totalIgnores = DEFAULT_IGNORES.size + Log.d(TAG, "Ignores migration completed, added $totalIgnores entries.") + }.getOrElse { + Log.e(TAG, "Failed to run ignores migration", it) + runCatching { + messageIgnoreDao.deleteAllIgnores() + userIgnoreDao.deleteAllIgnores() + return@launch } } + } fun isUserBlocked(userId: UserId?): Boolean = _twitchBlocks.value.any { it.id == userId } - suspend fun loadUserBlocks() = - withContext(Dispatchers.Default) { - if (!preferences.isLoggedIn) { + suspend fun loadUserBlocks() = withContext(Dispatchers.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 userId = preferences.userIdString ?: return@withContext - val blocks = - helixApiClient.getUserBlocks(userId).getOrElse { - Log.d(TAG, "Failed to load user blocks for $userId", it) - return@withContext - } - if (blocks.isEmpty()) { - _twitchBlocks.update { emptySet() } + 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 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 twitchBlocks = + users.mapTo(mutableSetOf()) { user -> + TwitchBlock( + id = user.id, + name = user.name, + ) + } - _twitchBlocks.update { twitchBlocks } - } + _twitchBlocks.update { twitchBlocks } + } suspend fun addUserBlock( targetUserId: UserId, @@ -351,20 +348,19 @@ class IgnoresRepository( 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 - pos.first + offset..pos.last + offset // add sum of changes to the emote position - } - emoteWithPos.copy(positions = adjusted) - } + ): 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 + pos.first + offset..pos.last + offset // add sum of changes to the emote position + } + emoteWithPos.copy(positions = adjusted) + } private operator fun IntRange.contains(other: IntRange): Boolean = other.first >= first && other.last <= last 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 8a3ff5171..b1a169e8d 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 @@ -25,12 +25,11 @@ class RepliesRepository( ) { 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) } - listOf(root) + replies - } ?: flowOf(emptyList()) + 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) } + listOf(root) + replies + } ?: flowOf(emptyList()) fun hasMessageThread(rootMessageId: String) = threads.containsKey(rootMessageId) 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 f7c7c9a5b..221f84f8a 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 @@ -84,10 +84,9 @@ class ChannelRepository( 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) - } + fun getRoomStateFlow(channel: UserName): SharedFlow = roomStateFlows.getOrPut(channel) { + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + } fun getRoomState(channel: UserName): RoomState? = roomStateFlows[channel]?.firstValueOrNull @@ -109,46 +108,44 @@ class ChannelRepository( flow.tryEmit(state) } - suspend fun getChannelsByIds(ids: Collection): List = - withContext(Dispatchers.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 getChannelsByIds(ids: Collection): List = withContext(Dispatchers.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 } - suspend fun getChannels(names: Collection): List = - withContext(Dispatchers.IO) { - val cached = names.mapNotNull { channelCache[it] } - val cachedNames = cached.mapTo(mutableSetOf(), Channel::name) - val remaining = names - cachedNames - 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) } - 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 + } - channels.forEach { channelCache[it.name] = it } - return@withContext cached + channels + suspend fun getChannels(names: Collection): List = withContext(Dispatchers.IO) { + val cached = names.mapNotNull { channelCache[it] } + val cachedNames = cached.mapTo(mutableSetOf(), Channel::name) + val remaining = names - cachedNames + 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) } + + channels.forEach { channelCache[it.name] = it } + return@withContext cached + channels + } + fun cacheChannels(channels: List) { channels.forEach { channelCache[it.name] = it } } 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 index a58b85857..fc138f48d 100644 --- 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 @@ -60,13 +60,12 @@ class ChatConnector( } } - fun closeAndReconnect(channels: List) = - scope.launch { - readConnection.close() - writeConnection.close() - eventSubManager.close() - connectAndJoin(channels) - } + fun closeAndReconnect(channels: List) = scope.launch { + readConnection.close() + writeConnection.close() + eventSubManager.close() + connectAndJoin(channels) + } fun reconnect(reconnectPubsub: Boolean = true) { readConnection.reconnect() 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 index cc279e977..f4e638296 100644 --- 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 @@ -586,41 +586,39 @@ class ChatEventProcessor( } } - private fun ConnectionState.toSystemMessageType(): SystemMessageType = - when (this) { - ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected + private fun ConnectionState.toSystemMessageType(): SystemMessageType = when (this) { + ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected - ConnectionState.CONNECTED, - ConnectionState.CONNECTED_NOT_LOGGED_IN, - -> SystemMessageType.Connected - } + 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)) - } + ): 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)) - } + 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) - } + else -> { + TextResource.Plain(reason) } + } companion object { private val TAG = ChatEventProcessor::class.java.simpleName 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 index f21e05d14..e4fcea70d 100644 --- 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 @@ -140,23 +140,22 @@ class ChatMessageRepository( } } - suspend fun reparseAllEmotesAndBadges() = - withContext(Dispatchers.Default) { - messages.values - .map { flow -> - async { - flow.update { items -> - items.map { - it.copy( - tag = it.tag + 1, - message = messageProcessor.reparseEmotesAndBadges(it.message), - ) - } + suspend fun reparseAllEmotesAndBadges() = withContext(Dispatchers.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() - } + } + }.awaitAll() + chatNotificationRepository.reparseAll() + } fun addSystemMessage( channel: UserName, 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 index edaf0630d..637b03fb1 100644 --- 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 @@ -133,23 +133,22 @@ class ChatMessageSender( } } - 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) - } + 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) - } + else -> { + SystemMessageType.SendFailed(message) } + } companion object { private val TAG = ChatMessageSender::class.java.simpleName 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 index 57ea6084b..2992451f6 100644 --- 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 @@ -117,15 +117,13 @@ class ChatNotificationRepository( _channelMentionCount.increment(channel, count) } - fun clearMentionCount(channel: UserName) = - with(_channelMentionCount) { - tryEmit(firstValue.apply { set(channel, 0) }) - } + 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 clearMentionCounts() = with(_channelMentionCount) { + tryEmit(firstValue.apply { keys.forEach { if (it != WhisperMessage.WHISPER_CHANNEL) set(it, 0) } }) + } fun clearUnreadMessage(channel: UserName) { _unreadMessagesMap.assign(channel, false) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt index b9d5643ac..fc9318bf8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/MessageProcessor.kt @@ -31,38 +31,34 @@ class MessageProcessor( suspend fun processIrcMessage( ircMessage: IrcMessage, findMessageById: (UserName, String) -> Message? = { _, _ -> null }, - ): Message? = - Message - .parse(ircMessage, channelRepository::tryGetUserNameById) - ?.let { process(it, findMessageById) } + ): 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() + ): 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() + 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() + 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() 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 index 58d4bd7f0..e113d6119 100644 --- 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 @@ -46,104 +46,103 @@ class RecentMessagesHandler( suspend fun load( channel: UserName, isReconnect: Boolean = false, - ): Result = - withContext(Dispatchers.IO) { - if (!isReconnect && channel in loadedChannels) { + ): Result = withContext(Dispatchers.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()) } - 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 } - 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 + when (parsedIrc.command) { + "CLEARCHAT" -> { + val parsed = + runCatching { + ModerationMessage.parseClearChat(parsedIrc) + }.getOrNull() ?: continue - items.replaceOrAddHistoryModerationMessage(parsed) - } + items.replaceOrAddHistoryModerationMessage(parsed) + } - "CLEARMSG" -> { - val parsed = - runCatching { - ModerationMessage.parseClearMessage(parsedIrc) - }.getOrNull() ?: continue + "CLEARMSG" -> { + val parsed = + runCatching { + ModerationMessage.parseClearMessage(parsedIrc) + }.getOrNull() ?: continue - items += ChatItem(parsed, importance = ChatImportance.SYSTEM) - } + 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 - if (message.color != Message.DEFAULT_COLOR) { - usersRepository.cacheUserColor(message.name, message.color) - } + 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 + if (message.color != Message.DEFAULT_COLOR) { + usersRepository.cacheUserColor(message.name, message.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) + val importance = + when { + isDeleted -> ChatImportance.DELETED + isReconnect -> ChatImportance.SYSTEM + else -> ChatImportance.REGULAR } - items += ChatItem(message, importance = importance) + 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") } - - 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 - } + } + }.let { Log.i(TAG, "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() } - withIncompleteWarning.addAndLimit(items, chatMessageRepository.scrollBackLength, messageProcessor::onMessageRemoved, checkForDuplications = true) - } + else -> { + current + } + } - val mentionItems = items.filter { it.message.highlights.hasMention() }.toMentionTabItems() - Result(mentionItems, userSuggestions) + 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, 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 068872e6f..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,11 +15,10 @@ 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 - } + fun getSendDelay(channel: UserName): Duration = when { + hasHighRateLimit(channel) -> LOW_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 f438f0280..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 @@ -24,20 +24,18 @@ class UserStateRepository( 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() + 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() suspend fun tryGetUserStateOrFallback( minChannelsSize: Int, initialTimeout: Duration = IRC_TIMEOUT_DELAY, fallbackTimeout: Duration = IRC_TIMEOUT_SHORT_DELAY, - ): UserState? = - withTimeoutOrNull(initialTimeout) { getLatestValidUserState(minChannelsSize) } - ?: withTimeoutOrNull(fallbackTimeout) { getLatestValidUserState(minChannelsSize = 0) } + ): UserState? = withTimeoutOrNull(initialTimeout) { getLatestValidUserState(minChannelsSize) } + ?: withTimeoutOrNull(fallbackTimeout) { getLatestValidUserState(minChannelsSize = 0) } fun isModeratorInChannel(channel: UserName?): Boolean = channel != null && channel in userState.value.moderationChannels 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 ab6c25a14..1ae4303c0 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 @@ -77,11 +77,10 @@ class CommandRepository( } } - fun getCommandTriggers(channel: UserName): Flow> = - when (channel) { - WhisperMessage.WHISPER_CHANNEL -> flowOf(TwitchCommandRepository.asCommandTriggers(TwitchCommand.Whisper.trigger)) - else -> commandTriggers - } + fun getCommandTriggers(channel: UserName): Flow> = when (channel) { + WhisperMessage.WHISPER_CHANNEL -> flowOf(TwitchCommandRepository.asCommandTriggers(TwitchCommand.Whisper.trigger)) + else -> commandTriggers + } fun getSupibotCommands(channel: UserName): StateFlow> = supibotCommands.getOrPut(channel) { MutableStateFlow(emptyList()) } @@ -163,28 +162,27 @@ class CommandRepository( } } - suspend fun loadSupibotCommands() = - withContext(Dispatchers.Default) { - if (!authDataStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { - return@withContext - } + suspend fun loadSupibotCommands() = withContext(Dispatchers.Default) { + if (!authDataStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { + return@withContext + } - measureTimeMillis { - val channelsDeferred = async { getSupibotChannels() } - val commandsDeferred = async { getSupibotCommands() } - val aliasesDeferred = async { getSupibotUserAliases() } + measureTimeMillis { + val channelsDeferred = async { getSupibotChannels() } + val commandsDeferred = async { getSupibotCommands() } + val aliasesDeferred = async { getSupibotUserAliases() } - val channels = channelsDeferred.await() - val commands = commandsDeferred.await() - val aliases = aliasesDeferred.await() + val channels = channelsDeferred.await() + val commands = commandsDeferred.await() + val aliases = aliasesDeferred.await() - channels.forEach { - supibotCommands - .getOrPut(it) { MutableStateFlow(emptyList()) } - .update { commands + aliases } - } - }.let { Log.i(TAG, "Loaded Supibot commands in $it ms") } - } + channels.forEach { + supibotCommands + .getOrPut(it) { MutableStateFlow(emptyList()) } + .update { commands + aliases } + } + }.let { Log.i(TAG, "Loaded Supibot commands in $it ms") } + } private fun triggerAndArgsOrNull(message: String): Pair>? { val words = message.split(" ") @@ -200,23 +198,21 @@ class CommandRepository( return trigger to words.drop(1) } - 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 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 = authDataStore.userName ?: return emptyList() @@ -228,10 +224,9 @@ class CommandRepository( }.orEmpty() } - private fun clearSupibotCommands() = - supibotCommands - .forEach { it.value.value = emptyList() } - .also { supibotCommands.clear() } + private fun clearSupibotCommands() = supibotCommands + .forEach { it.value.value = emptyList() } + .also { supibotCommands.clear() } private suspend fun blockUserCommand(args: List): CommandResult.AcceptedWithResponse { if (args.isEmpty() || args.first().isBlank()) { 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 ea5b8557a..62b65414f 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 @@ -127,43 +127,39 @@ class DataRepository( } } - suspend fun uploadMedia(file: File): Result = - uploadClient.uploadMedia(file).mapCatching { - recentUploadsRepository.addUpload(it) - it.imageLink - } + suspend fun uploadMedia(file: File): Result = uploadClient.uploadMedia(file).mapCatching { + recentUploadsRepository.addUpload(it) + it.imageLink + } - suspend fun loadGlobalBadges(): Result = - withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "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 loadGlobalBadges(): Result = withContext(Dispatchers.IO) { + measureTimeAndLog(TAG, "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(): Result = - withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "DankChat badges") { - dankChatApiClient - .getDankChatBadges() - .getOrEmitFailure { DataLoadingStep.DankChatBadges } - .onSuccess { emoteRepository.setDankChatBadges(it) } - .map { } - } + suspend fun loadDankChatBadges(): Result = withContext(Dispatchers.IO) { + measureTimeAndLog(TAG, "DankChat badges") { + dankChatApiClient + .getDankChatBadges() + .getOrEmitFailure { DataLoadingStep.DankChatBadges } + .onSuccess { emoteRepository.setDankChatBadges(it) } + .map { } } + } suspend fun loadUserEmotes( userId: UserId, onFirstPageLoaded: (() -> Unit)? = null, - ): Result = - emoteRepository - .loadUserEmotes(userId, onFirstPageLoaded) - .getOrEmitFailure { DataLoadingStep.TwitchEmotes } + ): Result = emoteRepository + .loadUserEmotes(userId, onFirstPageLoaded) + .getOrEmitFailure { DataLoadingStep.TwitchEmotes } suspend fun loadUserStateEmotes( globalEmoteSetIds: List, @@ -179,148 +175,139 @@ class DataRepository( suspend fun loadChannelBadges( channel: UserName, id: UserId, - ): Result = - withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "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 { } - } + ): Result = withContext(Dispatchers.IO) { + measureTimeAndLog(TAG, "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, - ): Result = - withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + ): Result = withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "FFZ emotes for #$channel") { - ffzApiClient - .getFFZChannelEmotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } - .onSuccess { it?.let { emoteRepository.setFFZEmotes(channel, it) } } - .map { } - } + measureTimeAndLog(TAG, "FFZ emotes for #$channel") { + ffzApiClient + .getFFZChannelEmotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } + .onSuccess { it?.let { emoteRepository.setFFZEmotes(channel, it) } } + .map { } } + } suspend fun loadChannelBTTVEmotes( channel: UserName, channelDisplayName: DisplayName, channelId: UserId, - ): Result = - withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + ): Result = withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "BTTV emotes for #$channel") { - bttvApiClient - .getBTTVChannelEmotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } - .onSuccess { it?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } - .map { } - } + measureTimeAndLog(TAG, "BTTV emotes for #$channel") { + bttvApiClient + .getBTTVChannelEmotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } + .onSuccess { it?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } + .map { } } + } suspend fun loadChannelSevenTVEmotes( channel: UserName, channelId: UserId, - ): Result = - withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + ): Result = withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) + } - measureTimeAndLog(TAG, "7TV emotes for #$channel") { - sevenTVApiClient - .getSevenTVChannelEmotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelSevenTVEmotes(channel, channelId) } - .onSuccess { result -> - result ?: return@onSuccess - if (result.emoteSet?.id != null) { - sevenTVEventApiClient.subscribeEmoteSet(result.emoteSet.id) - } - sevenTVEventApiClient.subscribeUser(result.user.id) - emoteRepository.setSevenTVEmotes(channel, result) - }.map { } - } + measureTimeAndLog(TAG, "7TV emotes for #$channel") { + sevenTVApiClient + .getSevenTVChannelEmotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelSevenTVEmotes(channel, channelId) } + .onSuccess { result -> + result ?: return@onSuccess + if (result.emoteSet?.id != null) { + sevenTVEventApiClient.subscribeEmoteSet(result.emoteSet.id) + } + sevenTVEventApiClient.subscribeUser(result.user.id) + emoteRepository.setSevenTVEmotes(channel, result) + }.map { } } + } suspend fun loadChannelCheermotes( channel: UserName, channelId: UserId, - ): Result = - withContext(Dispatchers.IO) { - if (!authDataStore.isLoggedIn) { - return@withContext Result.success(Unit) - } - - measureTimeAndLog(TAG, "cheermotes for #$channel") { - helixApiClient - .getCheermotes(channelId) - .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } - .onSuccess { emoteRepository.setCheermotes(channel, it) } - .map { } - } + ): Result = withContext(Dispatchers.IO) { + if (!authDataStore.isLoggedIn) { + return@withContext Result.success(Unit) } - suspend fun loadGlobalFFZEmotes(): Result = - withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + measureTimeAndLog(TAG, "cheermotes for #$channel") { + helixApiClient + .getCheermotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } + .onSuccess { emoteRepository.setCheermotes(channel, it) } + .map { } + } + } - measureTimeAndLog(TAG, "global FFZ emotes") { - ffzApiClient - .getFFZGlobalEmotes() - .getOrEmitFailure { DataLoadingStep.GlobalFFZEmotes } - .onSuccess { emoteRepository.setFFZGlobalEmotes(it) } - .map { } - } + suspend fun loadGlobalFFZEmotes(): Result = withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) } - suspend fun loadGlobalBTTVEmotes(): Result = - withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + measureTimeAndLog(TAG, "global FFZ emotes") { + ffzApiClient + .getFFZGlobalEmotes() + .getOrEmitFailure { DataLoadingStep.GlobalFFZEmotes } + .onSuccess { emoteRepository.setFFZGlobalEmotes(it) } + .map { } + } + } - measureTimeAndLog(TAG, "global BTTV emotes") { - bttvApiClient - .getBTTVGlobalEmotes() - .getOrEmitFailure { DataLoadingStep.GlobalBTTVEmotes } - .onSuccess { emoteRepository.setBTTVGlobalEmotes(it) } - .map { } - } + suspend fun loadGlobalBTTVEmotes(): Result = withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) } - suspend fun loadGlobalSevenTVEmotes(): Result = - withContext(Dispatchers.IO) { - if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext Result.success(Unit) - } + measureTimeAndLog(TAG, "global BTTV emotes") { + bttvApiClient + .getBTTVGlobalEmotes() + .getOrEmitFailure { DataLoadingStep.GlobalBTTVEmotes } + .onSuccess { emoteRepository.setBTTVGlobalEmotes(it) } + .map { } + } + } - measureTimeAndLog(TAG, "global 7TV emotes") { - sevenTVApiClient - .getSevenTVGlobalEmotes() - .getOrEmitFailure { DataLoadingStep.GlobalSevenTVEmotes } - .onSuccess { emoteRepository.setSevenTVGlobalEmotes(it) } - .map { } - } + suspend fun loadGlobalSevenTVEmotes(): Result = withContext(Dispatchers.IO) { + if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { + return@withContext Result.success(Unit) } - private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = - onFailure { throwable -> - Log.e(TAG, "Data request failed:", throwable) - _dataLoadingFailures.update { it + DataLoadingFailure(step(), throwable) } + measureTimeAndLog(TAG, "global 7TV emotes") { + sevenTVApiClient + .getSevenTVGlobalEmotes() + .getOrEmitFailure { DataLoadingStep.GlobalSevenTVEmotes } + .onSuccess { emoteRepository.setSevenTVGlobalEmotes(it) } + .map { } } + } + + private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = onFailure { throwable -> + Log.e(TAG, "Data request failed:", throwable) + _dataLoadingFailures.update { it + DataLoadingFailure(step(), throwable) } + } companion object { private val TAG = DataRepository::class.java.simpleName 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 18edba9c4..41085c006 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 @@ -314,18 +314,17 @@ class EmoteRepository( val tag: String, ) - private fun String.parseTagList(): List = - split(',') - .mapNotNull { - if (!it.contains('/')) { - return@mapNotNull null - } - - val key = it.substringBefore('/') - val value = it.substringAfter('/') - TagListEntry(key, value, it) + private fun String.parseTagList(): List = split(',') + .mapNotNull { + if (!it.contains('/')) { + return@mapNotNull null } + val key = it.substringBefore('/') + val value = it.substringAfter('/') + TagListEntry(key, value, it) + } + private fun getChannelBadgeUrl( channel: UserName?, set: String, @@ -347,15 +346,14 @@ class EmoteRepository( channel: UserName?, set: String, version: String, - ): String? = - channel?.let { - channelBadges[channel] - ?.get(set) - ?.versions - ?.get(version) - ?.title - } - ?: globalBadges[set]?.versions?.get(version)?.title + ): 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] } @@ -394,21 +392,19 @@ class EmoteRepository( dankChatBadges.addAll(dto) } - fun getChannelForSevenTVEmoteSet(emoteSetId: String): UserName? = - sevenTvChannelDetails - .entries - .find { (_, details) -> details.activeEmoteSetId == emoteSetId } - ?.key + fun getChannelForSevenTVEmoteSet(emoteSetId: String): UserName? = sevenTvChannelDetails + .entries + .find { (_, details) -> details.activeEmoteSetId == emoteSetId } + ?.key fun getSevenTVUserDetails(channel: UserName): SevenTVUserDetails? = sevenTvChannelDetails[channel] suspend fun loadUserEmotes( userId: UserId, onFirstPageLoaded: (() -> Unit)? = null, - ): Result = - runCatching { - loadUserEmotesViaHelix(userId, onFirstPageLoaded) - } + ): Result = runCatching { + loadUserEmotesViaHelix(userId, onFirstPageLoaded) + } private suspend fun loadUserEmotesViaHelix( userId: UserId, @@ -562,18 +558,17 @@ class EmoteRepository( } } - 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(Dispatchers.Default) { + val ffzGlobalEmotes = + ffzResult.sets + .filter { it.key in ffzResult.defaultSets } + .flatMap { (_, emoteSet) -> + emoteSet.emotes.mapNotNull { emote -> + parseFFZEmote(emote, channel = null) } - globalEmoteState.update { it.copy(ffzEmotes = ffzGlobalEmotes) } - } + } + globalEmoteState.update { it.copy(ffzEmotes = ffzGlobalEmotes) } + } suspend fun setBTTVEmotes( channel: UserName, @@ -586,11 +581,10 @@ class EmoteRepository( } } - suspend fun setBTTVGlobalEmotes(globalEmotes: List) = - withContext(Dispatchers.Default) { - val bttvGlobalEmotes = globalEmotes.map { parseBTTVGlobalEmote(it) } - globalEmoteState.update { it.copy(bttvEmotes = bttvGlobalEmotes) } - } + suspend fun setBTTVGlobalEmotes(globalEmotes: List) = withContext(Dispatchers.Default) { + val bttvGlobalEmotes = globalEmotes.map { parseBTTVGlobalEmote(it) } + globalEmoteState.update { it.copy(bttvEmotes = bttvGlobalEmotes) } + } suspend fun setSevenTVEmotes( channel: UserName, @@ -672,19 +666,18 @@ class EmoteRepository( } } - suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = - withContext(Dispatchers.Default) { - if (sevenTvResult.isEmpty()) return@withContext + suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = withContext(Dispatchers.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) } - } + globalEmoteState.update { it.copy(sevenTvEmotes = sevenTvGlobalEmotes) } + } suspend fun setCheermotes( channel: UserName, @@ -782,23 +775,22 @@ class EmoteRepository( ) } - private fun List?.mapToGenericEmotes(type: EmoteType): List = - this - ?.map { (name, id) -> - val code = - when (type) { - is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name - else -> name - } - GenericEmote( - code = code, - url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), - lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), - id = id, - scale = 1, - emoteType = type, - ) - }.orEmpty() + private fun List?.mapToGenericEmotes(type: EmoteType): List = this + ?.map { (name, id) -> + val code = + when (type) { + is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name + else -> name + } + GenericEmote( + code = code, + url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), + lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), + id = id, + scale = 1, + emoteType = type, + ) + }.orEmpty() @VisibleForTesting fun adjustOverlayEmotes( @@ -885,29 +877,28 @@ class EmoteRepository( appendedSpaces: List, removedSpaces: List, replyMentionOffset: Int, - ): List = - emotesWithPositions.flatMap { (id, positions) -> - positions.map { range -> - val removedSpaceExtra = countLessThan(removedSpaces, range.first) - val unicodeExtra = countLessThan(supplementaryCodePointPositions, range.first - removedSpaceExtra) - val spaceExtra = countLessThan(appendedSpaces, range.first + unicodeExtra) - val fixedStart = range.first + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset - val fixedEnd = range.last + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset - - // 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), - id = id, - code = code, - scale = 1, - type = ChatMessageEmoteType.TwitchEmote, - isTwitch = true, - ) - } + ): List = emotesWithPositions.flatMap { (id, positions) -> + positions.map { range -> + val removedSpaceExtra = countLessThan(removedSpaces, range.first) + val unicodeExtra = countLessThan(supplementaryCodePointPositions, range.first - removedSpaceExtra) + val spaceExtra = countLessThan(appendedSpaces, range.first + unicodeExtra) + val fixedStart = range.first + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset + val fixedEnd = range.last + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset + + // 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), + id = id, + code = code, + scale = 1, + type = ChatMessageEmoteType.TwitchEmote, + isTwitch = true, + ) } + } private fun parseBTTVEmote( emote: BTTVEmoteDto, @@ -999,11 +990,10 @@ class EmoteRepository( 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 } - } + private suspend fun List.filterUnlistedIfEnabled(): List = when { + chatSettingsDataStore.settings.first().allowUnlistedSevenTvEmotes -> this + else -> filter { it.data?.listed == true } + } private val String.withLeadingHttps: String get() = 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 e66b1a256..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 @@ -16,38 +16,36 @@ data class BadgeVersion( val imageUrlHigh: String, ) -fun TwitchBadgeSetsDto.toBadgeSets(): Map = - sets.mapValues { (id, set) -> +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, + ) + }, + ) +} + +fun List.toBadgeSets(): Map = associate { (id, versions) -> + id to 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.associate { badge -> + badge.id to + BadgeVersion( + id = badge.id, + 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, - ) - }, - ) - } +} 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 f15be2a93..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 @@ -13,13 +13,12 @@ enum class BadgeType { // FrankerFaceZ; companion object { - fun parseFromBadgeId(id: String): BadgeType = - when (id) { - "staff", "admin", "global_admin" -> Authority - "predictions" -> Predictions - "lead_moderator", "moderator", "vip", "broadcaster" -> Channel - "subscriber", "founder" -> Subscriber - else -> Vanity - } + fun parseFromBadgeId(id: String): BadgeType = when (id) { + "staff", "admin", "global_admin" -> Authority + "predictions" -> Predictions + "lead_moderator", "moderator", "vip", "broadcaster" -> Channel + "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 a4a6bc001..b621c4558 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 @@ -323,41 +323,39 @@ class ChatConnection( private fun randomJitter() = Random.nextLong(range = 0L..MAX_JITTER).milliseconds - private fun setupPingInterval() = - scope.timer(interval = PING_INTERVAL - randomJitter()) { - val currentSession = session - if (awaitingPong || currentSession?.isActive != true) { - cancel() - reconnect() - return@timer - } + private fun setupPingInterval() = scope.timer(interval = PING_INTERVAL - randomJitter()) { + val currentSession = session + if (awaitingPong || currentSession?.isActive != true) { + cancel() + reconnect() + return@timer + } - if (_connected.value) { - awaitingPong = true - runCatching { currentSession.send(Frame.Text("PING\r\n")) } - } + if (_connected.value) { + awaitingPong = true + 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") - if (session?.isActive != true || !_connected.value || channelsAttemptedToJoin.isEmpty()) { - return@launch - } + private fun setupJoinCheckInterval(channelsToCheck: List) = scope.launch { + Log.d(TAG, "[$chatConnectionType] setting up join check for $channelsToCheck") + if (session?.isActive != true || !_connected.value || channelsAttemptedToJoin.isEmpty()) { + return@launch + } - delay(JOIN_CHECK_DELAY) - if (session?.isActive != true || !_connected.value) { - channelsAttemptedToJoin.removeAll(channelsToCheck.toSet()) - return@launch - } + delay(JOIN_CHECK_DELAY) + if (session?.isActive != true || !_connected.value) { + channelsAttemptedToJoin.removeAll(channelsToCheck.toSet()) + return@launch + } - channelsToCheck.forEach { - if (it in channelsAttemptedToJoin) { - channelsAttemptedToJoin.remove(it) - receiveChannel.send(ChatEvent.ChannelNonExistent(it)) - } + channelsToCheck.forEach { + if (it in channelsAttemptedToJoin) { + channelsAttemptedToJoin.remove(it) + receiveChannel.send(ChatEvent.ChannelNonExistent(it)) } } + } private suspend fun DefaultClientWebSocketSession.sendIrc(msg: String) { send(Frame.Text("${msg.trimEnd()}\r\n")) 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 c33d6fc18..84a594bd8 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 @@ -202,25 +202,24 @@ class TwitchCommandRepository( 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)) - } + ): CommandResult = helixApiClient.getModerators(context.channelId).fold( + onSuccess = { result -> + when { + result.isEmpty() -> { + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_no_moderators)) + } - else -> { - val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } - CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_moderators_list, persistentListOf(users))) - } + 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) - }, - ) + } + }, + 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, @@ -275,25 +274,24 @@ class TwitchCommandRepository( 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)) - } + ): CommandResult = helixApiClient.getVips(context.channelId).fold( + onSuccess = { result -> + when { + result.isEmpty() -> { + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_no_vips)) + } - else -> { - val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } - CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_vips_list, persistentListOf(users))) - } + 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) - }, - ) + } + }, + 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, @@ -449,14 +447,13 @@ class TwitchCommandRepository( 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) - }, - ) + ): 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, @@ -579,14 +576,13 @@ class TwitchCommandRepository( 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) - }, - ) + ): 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, @@ -745,14 +741,13 @@ class TwitchCommandRepository( context: CommandContext, request: ChatSettingsRequestDto, 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) - }, - ) + ): 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, 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 0e09d40e2..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 @@ -43,13 +43,12 @@ sealed interface ChatMessageEmoteType : Parcelable { 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.ChannelSevenTVEmote -> ChatMessageEmoteType.ChannelSevenTVEmote(creator, baseName) - EmoteType.GlobalBTTVEmote -> ChatMessageEmoteType.GlobalBTTVEmote - is EmoteType.GlobalFFZEmote -> ChatMessageEmoteType.GlobalFFZEmote(creator) - is EmoteType.GlobalSevenTVEmote -> ChatMessageEmoteType.GlobalSevenTVEmote(creator, baseName) - else -> null - } +fun EmoteType.toChatMessageEmoteType(): ChatMessageEmoteType? = when (this) { + 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 +} 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 9bab5543c..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 @@ -69,24 +69,23 @@ sealed interface EmoteType : Comparable { override val title = "" } - override fun compareTo(other: EmoteType): Int = - when { - this is ChannelTwitchBitEmote || this is ChannelTwitchFollowerEmote -> { - when (other) { - is ChannelTwitchBitEmote, - is ChannelTwitchFollowerEmote, - -> 0 - - else -> 1 - } + override fun compareTo(other: EmoteType): Int = when { + this is ChannelTwitchBitEmote || this is ChannelTwitchFollowerEmote -> { + when (other) { + is ChannelTwitchBitEmote, + is ChannelTwitchFollowerEmote, + -> 0 + + else -> 1 } + } - other is ChannelTwitchBitEmote || other is ChannelTwitchFollowerEmote -> { - -1 - } + other is ChannelTwitchBitEmote || other is ChannelTwitchFollowerEmote -> { + -1 + } - else -> { - 0 - } + else -> { + 0 } + } } 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 35c56bd90..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 @@ -7,10 +7,9 @@ enum class ThirdPartyEmoteType { ; 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/Message.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/Message.kt index 5c9083c26..8a3993bfb 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 @@ -33,15 +33,14 @@ sealed class Message { fun parse( message: IrcMessage, findChannel: (UserId) -> UserName?, - ): Message? = - with(message) { - return when (command) { - "PRIVMSG" -> PrivMessage.parsePrivMessage(message, findChannel) - "NOTICE" -> NoticeMessage.parseNotice(message) - "USERNOTICE" -> UserNoticeMessage.parseUserNotice(message, findChannel) - else -> null - } + ): Message? = with(message) { + return when (command) { + "PRIVMSG" -> PrivMessage.parsePrivMessage(message, findChannel) + "NOTICE" -> NoticeMessage.parseNotice(message) + "USERNOTICE" -> UserNoticeMessage.parseUserNotice(message, findChannel) + else -> null } + } fun parseEmoteTag( message: String, 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 8fdf76405..fec5a6324 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 @@ -83,11 +83,10 @@ data class ModerationMessage( }.takeIf { it.isNotEmpty() } } - private fun countSuffix(): TextResource = - when { - stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) - else -> TextResource.Plain("") - } + private fun countSuffix(): TextResource = when { + stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) + else -> TextResource.Plain("") + } fun getSystemMessage( currentUser: UserName?, @@ -360,64 +359,61 @@ data class ModerationMessage( 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])) - } + ): 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(::formatSecondsDuration) - val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val id = tags["id"] ?: "clearchat-$ts-$channel-${target ?: "all"}" - val action = - when { - target == null -> Action.Clear - durationSeconds == null -> Action.Ban - else -> Action.Timeout - } + 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(::formatSecondsDuration) + val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() + val id = tags["id"] ?: "clearchat-$ts-$channel-${target ?: "all"}" + val action = + when { + target == null -> Action.Clear + durationSeconds == null -> Action.Ban + else -> Action.Timeout + } - return ModerationMessage( - timestamp = ts, - id = id, - channel = channel.toUserName(), - action = action, - targetUserDisplay = target?.toDisplayName(), - targetUser = target?.toUserName(), - durationInt = durationSeconds, - duration = duration, - stackCount = if (target != null && duration != null) 1 else 0, - fromEventSource = false, - ) - } + return ModerationMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + action = action, + targetUserDisplay = target?.toDisplayName(), + targetUser = target?.toUserName(), + durationInt = durationSeconds, + duration = duration, + stackCount = if (target != null && duration != null) 1 else 0, + fromEventSource = false, + ) + } - fun parseClearMessage(message: IrcMessage): ModerationMessage = - with(message) { - val channel = params[0].substring(1) - val target = tags["login"] - val targetMsgId = tags["target-msg-id"] - val reason = params.getOrNull(1) - val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val id = tags["id"] ?: "clearmsg-$ts-$channel-${target ?: ""}" - - return ModerationMessage( - timestamp = ts, - id = id, - channel = channel.toUserName(), - action = Action.Delete, - targetUserDisplay = target?.toDisplayName(), - targetUser = target?.toUserName(), - targetMsgId = targetMsgId, - reason = reason, - fromEventSource = false, - ) - } + fun parseClearMessage(message: IrcMessage): ModerationMessage = with(message) { + val channel = params[0].substring(1) + val target = tags["login"] + val targetMsgId = tags["target-msg-id"] + val reason = params.getOrNull(1) + val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() + val id = tags["id"] ?: "clearmsg-$ts-$channel-${target ?: ""}" + + return ModerationMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + action = Action.Delete, + targetUserDisplay = target?.toDisplayName(), + targetUser = target?.toUserName(), + targetMsgId = targetMsgId, + reason = reason, + fromEventSource = false, + ) + } fun parseModerationAction( timestamp: Instant, @@ -482,148 +478,138 @@ data class ModerationMessage( private fun parseDuration( seconds: Int?, data: ModerationActionData, - ): TextResource? = - when (data.moderationAction) { - ModerationActionType.Timeout -> seconds?.let(::formatSecondsDuration) - else -> null - } + ): TextResource? = when (data.moderationAction) { + ModerationActionType.Timeout -> seconds?.let(::formatSecondsDuration) + else -> null + } private fun parseDuration( 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 - } + ): 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 + } - private fun parseReason(data: ModerationActionData): String? = - when (data.moderationAction) { - ModerationActionType.Ban, - ModerationActionType.Delete, - -> data.args?.getOrNull(1) + private fun parseReason(data: ModerationActionData): String? = when (data.moderationAction) { + ModerationActionType.Ban, + ModerationActionType.Delete, + -> data.args?.getOrNull(1) - ModerationActionType.Timeout -> data.args?.getOrNull(2) + ModerationActionType.Timeout -> data.args?.getOrNull(2) - else -> null - } + else -> null + } - private fun parseReason(data: ChannelModerateDto): String? = - when (data.action) { - ChannelModerateAction.Ban -> data.ban?.reason + private fun parseReason(data: ChannelModerateDto): String? = when (data.action) { + ChannelModerateAction.Ban -> data.ban?.reason - ChannelModerateAction.Delete -> data.delete?.messageBody + ChannelModerateAction.Delete -> data.delete?.messageBody - ChannelModerateAction.Timeout -> data.timeout?.reason + ChannelModerateAction.Timeout -> data.timeout?.reason - ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.reason + ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.reason - ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageBody + ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageBody - ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.reason + ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.reason - ChannelModerateAction.Warn -> data.warn?.let { listOfNotNull(it.reason).plus(it.chatRulesCited.orEmpty()).joinToString() } + 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\"" } + ChannelModerateAction.AddBlockedTerm, + ChannelModerateAction.AddPermittedTerm, + ChannelModerateAction.RemoveBlockedTerm, + ChannelModerateAction.RemovePermittedTerm, + -> data.automodTerms?.terms?.joinToString(" and ") { "\"$it\"" } - else -> null - } + else -> null + } - private fun parseTargetUser(data: ModerationActionData): UserName? = - when (data.moderationAction) { - ModerationActionType.Delete -> data.args?.getOrNull(0)?.toUserName() - else -> data.targetUserName - } + private fun parseTargetUser(data: ModerationActionData): UserName? = when (data.moderationAction) { + ModerationActionType.Delete -> data.args?.getOrNull(0)?.toUserName() + 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.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 - } + 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.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 + } - private fun parseTargetMsgId(data: ModerationActionData): String? = - when (data.moderationAction) { - ModerationActionType.Delete -> data.args?.getOrNull(2) - else -> null - } + private fun parseTargetMsgId(data: ModerationActionData): String? = when (data.moderationAction) { + ModerationActionType.Delete -> data.args?.getOrNull(2) + else -> null + } - private fun parseTargetMsgId(data: ChannelModerateDto): String? = - when (data.action) { - ChannelModerateAction.Delete -> data.delete?.messageId - ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageId - else -> null - } + private fun parseTargetMsgId(data: ChannelModerateDto): String? = when (data.action) { + ChannelModerateAction.Delete -> data.delete?.messageId + ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageId + else -> null + } - private fun ModerationActionType.toAction() = - when (this) { - ModerationActionType.Timeout -> Action.Timeout - 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 - } + private fun ModerationActionType.toAction() = when (this) { + ModerationActionType.Timeout -> Action.Timeout + 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 + } - 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 - ChannelModerateAction.AddBlockedTerm -> Action.AddBlockedTerm - ChannelModerateAction.AddPermittedTerm -> Action.AddPermittedTerm - ChannelModerateAction.RemoveBlockedTerm -> Action.RemoveBlockedTerm - ChannelModerateAction.RemovePermittedTerm -> Action.RemovePermittedTerm - else -> error("Unexpected moderation action $this") - } + 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 + 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 0bebf9096..28b37e879 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 @@ -14,36 +14,35 @@ data class NoticeMessage( 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] - } + 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] + } - else -> { - params[1] - } + else -> { + params[1] } + } - val ts = tags["rm-received-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val id = tags["id"] ?: UUID.randomUUID().toString() + val ts = tags["rm-received-ts"]?.toLongOrNull() ?: System.currentTimeMillis() + val id = tags["id"] ?: UUID.randomUUID().toString() - return NoticeMessage( - timestamp = ts, - id = id, - channel = channel.toUserName(), - message = notice, - ) - } + return NoticeMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + message = notice, + ) + } val ROOM_STATE_CHANGE_MSG_IDS = listOf( 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 1546befea..3fd81d50a 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 @@ -46,54 +46,53 @@ data class PrivMessage( 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()) - } + ): 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 displayName = tags["display-name"] ?: name + val color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) ?: DEFAULT_COLOR - 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 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) + } - 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 channel = params[0].substring(1).toUserName() + val sourceChannel = + tags["source-room-id"] + ?.takeIf { it.isNotEmpty() && it != tags["room-id"] } + ?.toUserId() + ?.let(findChannel) - return PrivMessage( - timestamp = ts, - channel = channel, - sourceChannel = sourceChannel, - name = name.toUserName(), - displayName = displayName.toDisplayName(), - color = color, - message = message, - isAction = isAction, - id = id, - userId = tags["user-id"]?.toUserId(), - timedOut = tags["rm-deleted"] == "1", - tags = tags, - ) - } + return PrivMessage( + timestamp = ts, + channel = channel, + sourceChannel = sourceChannel, + name = name.toUserName(), + displayName = displayName.toDisplayName(), + color = color, + message = message, + isAction = isAction, + id = id, + userId = tags["user-id"]?.toUserId(), + timedOut = tags["rm-deleted"] == "1", + tags = tags, + ) + } } } 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 9039ba106..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 @@ -33,62 +33,59 @@ data class RoomState( val followerModeDuration get() = tags[RoomStateTag.FOLLOW]?.takeIf { it >= 0 } val slowModeWaitTime get() = tags[RoomStateTag.SLOW]?.takeIf { it > 0 } - fun toDebugText(): String = - tags - .filter { (it.key == RoomStateTag.FOLLOW && it.value >= 0) || it.value > 0 } - .map { (tag, value) -> - when (tag) { - RoomStateTag.FOLLOW -> { - when (value) { - 0 -> "follow" - else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" - } + fun toDebugText(): String = tags + .filter { (it.key == RoomStateTag.FOLLOW && it.value >= 0) || it.value > 0 } + .map { (tag, value) -> + when (tag) { + RoomStateTag.FOLLOW -> { + when (value) { + 0 -> "follow" + else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" } + } - RoomStateTag.SLOW -> { - "slow(${DateTimeUtils.formatSeconds(value)})" - } + RoomStateTag.SLOW -> { + "slow(${DateTimeUtils.formatSeconds(value)})" + } - else -> { - tag.name.lowercase() - } + else -> { + tag.name.lowercase() } - }.joinToString() + } + }.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) - } + 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.SUBS -> { + TextResource.Res(R.string.room_state_subscriber_only) + } - RoomStateTag.R9K -> { - TextResource.Res(R.string.room_state_unique_chat) - } + 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.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))) - } + 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() + } + }.toImmutableList() - fun copyFromIrcMessage(msg: IrcMessage): RoomState = - copy( - tags = tags.mapValues { (key, value) -> msg.getRoomStateTag(key, value) }, - ) + fun copyFromIrcMessage(msg: IrcMessage): RoomState = copy( + tags = tags.mapValues { (key, value) -> msg.getRoomStateTag(key, value) }, + ) private fun IrcMessage.getRoomStateTag( tag: RoomStateTag, 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 2a96e35ff..8d13c54ea 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 @@ -9,11 +9,10 @@ data class UserDisplay( val color: Int?, ) -fun UserDisplayEntity.toUserDisplay() = - UserDisplay( - alias = alias?.takeIf { enabled && aliasEnabled && it.isNotBlank() }, - color = color.takeIf { enabled && colorEnabled }, - ) +fun UserDisplayEntity.toUserDisplay() = UserDisplay( + alias = alias?.takeIf { enabled && aliasEnabled && it.isNotBlank() }, + color = color.takeIf { enabled && colorEnabled }, +) @ColorInt fun UserDisplay?.colorOrElse( 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 4c467f7c9..fc84795eb 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 @@ -33,67 +33,66 @@ data class UserNoticeMessage( 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"] - } else { - val roomId = tags["room-id"] - val sourceRoomId = tags["source-room-id"] - if (roomId != null && sourceRoomId != null) { - mirrored = roomId != sourceRoomId - } - } - - if (mirrored && msgId != "announcement") { - return null + ): UserNoticeMessage? = with(message) { + var msgId = tags["msg-id"] + var mirrored = msgId == "sharedchatnotice" + if (mirrored) { + msgId = tags["source-msg-id"] + } else { + val roomId = tags["room-id"] + val sourceRoomId = tags["source-room-id"] + if (roomId != null && sourceRoomId != null) { + mirrored = roomId != sourceRoomId } + } - val id = tags["id"] ?: UUID.randomUUID().toString() - val channel = params[0].substring(1) - val defaultMessage = tags["system-msg"] ?: "" - val systemMsg = - when { - msgId == "announcement" -> { - "Announcement" - } + if (mirrored && msgId != "announcement") { + return null + } - 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 id = tags["id"] ?: UUID.randomUUID().toString() + val channel = params[0].substring(1) + val defaultMessage = tags["system-msg"] ?: "" + val systemMsg = + when { + msgId == "announcement" -> { + "Announcement" + } - historic -> { - params[1] + 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 } + } - else -> { - defaultMessage - } + historic -> { + params[1] } - 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 + else -> { + defaultMessage } + } + val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - return UserNoticeMessage( - timestamp = ts, - id = id, - channel = channel.toUserName(), - message = systemMsg, - childMessage = childMessage?.takeIf { it.message.isNotBlank() }, - tags = tags, - ) - } + val childMessage = + when (msgId) { + in USER_NOTICE_MSG_IDS_WITH_MESSAGE -> PrivMessage.parsePrivMessage(message, findChannel) + else -> null + } + + return UserNoticeMessage( + timestamp = ts, + id = id, + channel = channel.toUserName(), + message = systemMsg, + childMessage = childMessage?.takeIf { it.message.isNotBlank() }, + tags = tags, + ) + } } } 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 38c3e8700..7dd1fe51f 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 @@ -46,68 +46,66 @@ data class WhisperMessage( 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 message = params.getOrElse(1) { "" } + ): 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 message = params.getOrElse(1) { "" } - return WhisperMessage( - timestamp = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis(), - id = tags["id"] ?: UUID.randomUUID().toString(), - userId = tags["user-id"]?.toUserId(), - name = name.toUserName(), - displayName = displayName.toDisplayName(), - color = color, - recipientId = null, - recipientName = recipientName.toUserName(), - recipientDisplayName = recipientName, - recipientColor = recipientColor, - message = message, - rawEmotes = emoteTag, - rawBadges = tags["badges"], - rawBadgeInfo = tags["badge-info"], - ) - } + return WhisperMessage( + timestamp = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis(), + id = tags["id"] ?: UUID.randomUUID().toString(), + userId = tags["user-id"]?.toUserId(), + name = name.toUserName(), + displayName = displayName.toDisplayName(), + color = color, + recipientId = null, + recipientName = recipientName.toUserName(), + recipientDisplayName = recipientName, + recipientColor = recipientColor, + 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}" } - } + 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, - ) - } + 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, + ) + } } } 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 2993bd885..58d14e1e9 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 @@ -238,20 +238,19 @@ class PubSubConnection( private fun randomJitter() = Random.nextLong(range = 0L..MAX_JITTER).milliseconds - private fun setupPingInterval() = - scope.timer(interval = PING_INTERVAL - randomJitter()) { - val currentSession = session - if (awaitingPong || currentSession?.isActive != true) { - cancel() - reconnect() - return@timer - } + private fun setupPingInterval() = scope.timer(interval = PING_INTERVAL - randomJitter()) { + val currentSession = session + if (awaitingPong || currentSession?.isActive != true) { + cancel() + reconnect() + return@timer + } - if (connected) { - awaitingPong = true - runCatching { currentSession.send(Frame.Text(PING_PAYLOAD)) } - } + if (connected) { + awaitingPong = true + runCatching { currentSession.send(Frame.Text(PING_PAYLOAD)) } } + } /** * Handles a PubSub message. Returns true if the server requested a reconnect. 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 4538336fa..fbeb2f17f 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 @@ -101,19 +101,18 @@ class PubSubManager( 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)) - } - } + ): Set = buildSet { + val uid = userId.toUserId() + for (channel in channels) { + add(PubSubTopic.PointRedemptions(channelId = channel.id, channelName = channel.name)) if (shouldUsePubSub) { - add(PubSubTopic.Whispers(uid)) + add(PubSubTopic.ModeratorActions(userId = uid, channelId = channel.id, channelName = channel.name)) } } + if (shouldUsePubSub) { + add(PubSubTopic.Whispers(uid)) + } + } private fun listen(topics: Set) { val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return @@ -154,19 +153,18 @@ class PubSubManager( 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() } - }, - ) - } + 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() } + }, + ) + } private suspend fun PubSubConnection.collectEvents() { events.collect { 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 eda9d64fd..df356c96c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt @@ -8,13 +8,12 @@ import org.koin.core.annotation.Single @Module class CoroutineModule { @Single - fun provideDispatchersProvider(): DispatchersProvider = - object : DispatchersProvider { - override val default: CoroutineDispatcher = Dispatchers.Default - override val io: CoroutineDispatcher = Dispatchers.IO - override val main: CoroutineDispatcher = Dispatchers.Main - override val immediate: CoroutineDispatcher = Dispatchers.Main.immediate - } + fun provideDispatchersProvider(): DispatchersProvider = object : DispatchersProvider { + override val default: CoroutineDispatcher = Dispatchers.Default + override val io: CoroutineDispatcher = Dispatchers.IO + override val main: CoroutineDispatcher = Dispatchers.Main + override val immediate: CoroutineDispatcher = Dispatchers.Main.immediate + } } interface DispatchersProvider { 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 67cbbd248..6d7fce886 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt @@ -18,11 +18,10 @@ import org.koin.core.annotation.Single @Module class DatabaseModule { @Single - fun provideDatabase(context: Context): DankChatDatabase = - Room - .databaseBuilder(context, DankChatDatabase::class.java, DB_NAME) - .addMigrations(DankChatDatabase.MIGRATION_4_5) - .build() + 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() 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 ff2076f34..e656a616a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -55,84 +55,77 @@ class NetworkModule { @Single @Named(type = WebSocketOkHttpClient::class) - fun provideOkHttpClient(): OkHttpClient = - OkHttpClient - .Builder() - .callTimeout(20.seconds.toJavaDuration()) - .build() + fun provideOkHttpClient(): OkHttpClient = OkHttpClient + .Builder() + .callTimeout(20.seconds.toJavaDuration()) + .build() @Single @Named(type = UploadOkHttpClient::class) - fun provideUploadOkHttpClient(): OkHttpClient = - OkHttpClient - .Builder() - .callTimeout(60.seconds.toJavaDuration()) - .build() + fun provideUploadOkHttpClient(): OkHttpClient = OkHttpClient + .Builder() + .callTimeout(60.seconds.toJavaDuration()) + .build() @Single - fun provideJson(): Json = - Json { - explicitNulls = false - ignoreUnknownKeys = true - isLenient = true - coerceInputValues = true - } + fun provideJson(): Json = Json { + explicitNulls = false + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } @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 = HttpClient(OkHttp) { + install(Logging) { + level = LogLevel.INFO + logger = + object : Logger { + override fun log(message: String) { + Log.v("HttpClient", message) } - } - install(HttpCache) - install(UserAgent) { - agent = "dankchat/${BuildConfig.VERSION_NAME}" - } - install(ContentNegotiation) { - json(json) - } - install(HttpTimeout) { - connectTimeoutMillis = 15_000 - requestTimeoutMillis = 15_000 - socketTimeoutMillis = 15_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( @@ -157,34 +150,31 @@ class NetworkModule { ) @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( @@ -199,12 +189,11 @@ class NetworkModule { ) @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 index 3d3caac9d..0c0c1600f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -83,10 +83,9 @@ class ChannelDataCoordinator( } } - fun getChannelLoadingState(channel: UserName): StateFlow = - channelStates.getOrPut(channel) { - MutableStateFlow(ChannelLoadingState.Idle) - } + fun getChannelLoadingState(channel: UserName): StateFlow = channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) + } fun loadChannelData(channel: UserName) { scope.launch { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt index b0be9d20f..fe49a8d78 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -77,50 +77,48 @@ class ChannelDataLoader( suspend fun loadChannelBadges( channel: UserName, channelId: UserId, - ): ChannelLoadingFailure.Badges? = - dataRepository.loadChannelBadges(channel, channelId).fold( - onSuccess = { null }, - onFailure = { ChannelLoadingFailure.Badges(channel, channelId, it) }, - ) + ): 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(), - ) - } + ): 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/GetChannelsUseCase.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt index a81279331..92dbfe5f1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt @@ -16,39 +16,38 @@ import org.koin.core.annotation.Single 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 - } + 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 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 - channelRepository.cacheChannels(roomStateChannels) + val roomStateChannels = + roomStatePairs.map { + Channel(id = it.channelId, name = it.channel, displayName = it.channel.toDisplayName(), avatarUrl = null) + } + remainingPairs + channelRepository.cacheChannels(roomStateChannels) - channels + roomStateChannels - } + channels + roomStateChannels + } companion object { private const val IRC_TIMEOUT_DELAY = 5_000L diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt index 5925f8cda..04ad27c2b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -19,28 +19,26 @@ class GlobalDataLoader( 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 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() 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 9ae62499b..bf5585206 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -108,11 +108,10 @@ class DankChatPreferenceStore( 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) - } + ): 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 { val updated = channels - channel @@ -133,20 +132,19 @@ class DankChatPreferenceStore( } } - fun getChannelsWithRenamesFlow(): Flow> = - callbackFlow { - send(getChannelsWithRenames()) + 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) } - } + dankChatPreferences.registerOnSharedPreferenceChangeListener(listener) + awaitClose { dankChatPreferences.unregisterOnSharedPreferenceChangeListener(listener) } + } fun setRenamedChannel(channelWithRename: ChannelWithRename) { withChannelRenames { @@ -185,11 +183,10 @@ class DankChatPreferenceStore( channelRenames = renameMap.toJson() } - private fun String.toMutableMap(): MutableMap = - json - .decodeOrNull>(this) - .orEmpty() - .toMutableMap() + private fun String.toMutableMap(): MutableMap = json + .decodeOrNull>(this) + .orEmpty() + .toMutableMap() private fun Map.toJson(): String = json.encodeToString(this) 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 index 758f5c9ee..954034105 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -282,13 +282,12 @@ private fun rememberThemeState( 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) - } +): 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) +} private fun setDarkMode( themePreference: ThemePreference, 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 e2c2c1cac..17d03f65b 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 @@ -179,11 +179,10 @@ class ChatSettingsDataStore( 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, - ) + override suspend fun migrate(currentData: ChatSettings): ChatSettings = currentData.copy( + visibleBadges = currentData.visibleBadges.plus(VisibleBadges.SharedChat).distinct(), + sharedChatMigration = true, + ) override suspend fun cleanUp() = Unit } 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 4e3f174e7..b372a4735 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 @@ -30,118 +30,116 @@ class ChatSettingsViewModel( initialValue = initial.toState(), ) - fun onInteraction(interaction: ChatSettingsInteraction) = - viewModelScope.launch { - runCatching { - when (interaction) { - is ChatSettingsInteraction.Suggestions -> { - chatSettingsDataStore.update { it.copy(suggestions = interaction.value) } - } + fun onInteraction(interaction: ChatSettingsInteraction) = viewModelScope.launch { + runCatching { + when (interaction) { + is ChatSettingsInteraction.Suggestions -> { + chatSettingsDataStore.update { it.copy(suggestions = interaction.value) } + } - is ChatSettingsInteraction.SupibotSuggestions -> { - chatSettingsDataStore.update { it.copy(supibotSuggestions = interaction.value) } - } + is ChatSettingsInteraction.SupibotSuggestions -> { + chatSettingsDataStore.update { it.copy(supibotSuggestions = interaction.value) } + } - is ChatSettingsInteraction.CustomCommands -> { - chatSettingsDataStore.update { it.copy(customCommands = interaction.value) } - } + is ChatSettingsInteraction.CustomCommands -> { + chatSettingsDataStore.update { it.copy(customCommands = interaction.value) } + } - is ChatSettingsInteraction.AnimateGifs -> { - chatSettingsDataStore.update { it.copy(animateGifs = interaction.value) } - } + is ChatSettingsInteraction.AnimateGifs -> { + chatSettingsDataStore.update { it.copy(animateGifs = interaction.value) } + } - is ChatSettingsInteraction.ScrollbackLength -> { - chatSettingsDataStore.update { it.copy(scrollbackLength = interaction.value) } - } + is ChatSettingsInteraction.ScrollbackLength -> { + chatSettingsDataStore.update { it.copy(scrollbackLength = interaction.value) } + } - is ChatSettingsInteraction.ShowUsernames -> { - chatSettingsDataStore.update { it.copy(showUsernames = interaction.value) } - } + is ChatSettingsInteraction.ShowUsernames -> { + chatSettingsDataStore.update { it.copy(showUsernames = interaction.value) } + } - is ChatSettingsInteraction.UserLongClick -> { - chatSettingsDataStore.update { it.copy(userLongClickBehavior = interaction.value) } - } + is ChatSettingsInteraction.UserLongClick -> { + chatSettingsDataStore.update { it.copy(userLongClickBehavior = interaction.value) } + } - is ChatSettingsInteraction.ColorizeNicknames -> { - chatSettingsDataStore.update { it.copy(colorizeNicknames = interaction.value) } - } + is ChatSettingsInteraction.ColorizeNicknames -> { + chatSettingsDataStore.update { it.copy(colorizeNicknames = interaction.value) } + } - is ChatSettingsInteraction.ShowTimedOutMessages -> { - chatSettingsDataStore.update { it.copy(showTimedOutMessages = interaction.value) } - } + is ChatSettingsInteraction.ShowTimedOutMessages -> { + chatSettingsDataStore.update { it.copy(showTimedOutMessages = interaction.value) } + } - is ChatSettingsInteraction.ShowTimestamps -> { - chatSettingsDataStore.update { it.copy(showTimestamps = interaction.value) } - } + is ChatSettingsInteraction.ShowTimestamps -> { + chatSettingsDataStore.update { it.copy(showTimestamps = interaction.value) } + } - is ChatSettingsInteraction.TimestampFormat -> { - chatSettingsDataStore.update { it.copy(timestampFormat = interaction.value) } - } + is ChatSettingsInteraction.TimestampFormat -> { + chatSettingsDataStore.update { it.copy(timestampFormat = interaction.value) } + } - is ChatSettingsInteraction.Badges -> { - chatSettingsDataStore.update { it.copy(visibleBadges = 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.Emotes -> { + chatSettingsDataStore.update { it.copy(visibleEmotes = interaction.value) } + if (initial.visibleEmotes != interaction.value) { + _events.emit(ChatSettingsEvent.RestartRequired) } + } - is ChatSettingsInteraction.AllowUnlisted -> { - chatSettingsDataStore.update { it.copy(allowUnlistedSevenTvEmotes = interaction.value) } - if (initial.allowUnlistedSevenTvEmotes != interaction.value) { - _events.emit(ChatSettingsEvent.RestartRequired) - } + 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.LiveEmoteUpdates -> { + chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdates = interaction.value) } + } - is ChatSettingsInteraction.LiveEmoteUpdatesBehavior -> { - chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdatesBehavior = interaction.value) } - } + is ChatSettingsInteraction.LiveEmoteUpdatesBehavior -> { + chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdatesBehavior = interaction.value) } + } - is ChatSettingsInteraction.MessageHistory -> { - chatSettingsDataStore.update { it.copy(loadMessageHistory = interaction.value) } - } + is ChatSettingsInteraction.MessageHistory -> { + chatSettingsDataStore.update { it.copy(loadMessageHistory = interaction.value) } + } - is ChatSettingsInteraction.MessageHistoryAfterReconnect -> { - chatSettingsDataStore.update { it.copy(loadMessageHistoryOnReconnect = interaction.value) } - } + is ChatSettingsInteraction.MessageHistoryAfterReconnect -> { + chatSettingsDataStore.update { it.copy(loadMessageHistoryOnReconnect = interaction.value) } + } - is ChatSettingsInteraction.ChatModes -> { - chatSettingsDataStore.update { it.copy(showChatModes = interaction.value) } - } + is ChatSettingsInteraction.ChatModes -> { + chatSettingsDataStore.update { it.copy(showChatModes = interaction.value) } } } } + } } -private fun ChatSettings.toState() = - ChatSettingsState( - suggestions = suggestions, - supibotSuggestions = supibotSuggestions, - customCommands = customCommands.toImmutableList(), - animateGifs = animateGifs, - scrollbackLength = scrollbackLength, - showUsernames = showUsernames, - userLongClickBehavior = userLongClickBehavior, - colorizeNicknames = colorizeNicknames, - showTimedOutMessages = showTimedOutMessages, - showTimestamps = showTimestamps, - timestampFormat = timestampFormat, - visibleBadges = visibleBadges.toImmutableList(), - visibleEmotes = visibleEmotes.toImmutableList(), - allowUnlistedSevenTvEmotes = allowUnlistedSevenTvEmotes, - sevenTVLiveEmoteUpdates = sevenTVLiveEmoteUpdates, - sevenTVLiveEmoteUpdatesBehavior = sevenTVLiveEmoteUpdatesBehavior, - loadMessageHistory = loadMessageHistory, - loadMessageHistoryAfterReconnect = loadMessageHistoryOnReconnect, - messageHistoryDashboardUrl = RECENT_MESSAGES_DASHBOARD, - showChatModes = showChatModes, - ) +private fun ChatSettings.toState() = ChatSettingsState( + suggestions = suggestions, + supibotSuggestions = supibotSuggestions, + customCommands = customCommands.toImmutableList(), + animateGifs = animateGifs, + scrollbackLength = scrollbackLength, + showUsernames = showUsernames, + userLongClickBehavior = userLongClickBehavior, + colorizeNicknames = colorizeNicknames, + showTimedOutMessages = showTimedOutMessages, + showTimestamps = showTimestamps, + timestampFormat = timestampFormat, + visibleBadges = visibleBadges.toImmutableList(), + visibleEmotes = visibleEmotes.toImmutableList(), + allowUnlistedSevenTvEmotes = allowUnlistedSevenTvEmotes, + sevenTVLiveEmoteUpdates = sevenTVLiveEmoteUpdates, + sevenTVLiveEmoteUpdatesBehavior = sevenTVLiveEmoteUpdatesBehavior, + loadMessageHistory = loadMessageHistory, + loadMessageHistoryAfterReconnect = loadMessageHistoryOnReconnect, + messageHistoryDashboardUrl = RECENT_MESSAGES_DASHBOARD, + showChatModes = showChatModes, +) private const val RECENT_MESSAGES_DASHBOARD = "https://recent-messages.robotty.de" 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 80d6120bc..6a7a31946 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 @@ -26,9 +26,8 @@ class CommandsViewModel( initialValue = chatSettingsDataStore.current().customCommands.toImmutableList(), ) - fun save(commands: List) = - viewModelScope.launch { - val filtered = commands.filter { it.trigger.isNotBlank() && it.command.isNotBlank() } - chatSettingsDataStore.update { it.copy(customCommands = filtered) } - } + fun save(commands: List) = viewModelScope.launch { + val filtered = commands.filter { it.trigger.isNotBlank() && it.command.isNotBlank() } + chatSettingsDataStore.update { it.copy(customCommands = filtered) } + } } 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 dd26e5c5f..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 @@ -13,27 +13,25 @@ data class UserDisplayItem( val alias: String, ) -fun UserDisplayItem.toEntity() = - UserDisplayEntity( - id = id, - // prevent whitespace before/after name from messing up with matching - targetUser = username.trim(), - enabled = enabled, - colorEnabled = colorEnabled, - color = color, - aliasEnabled = aliasEnabled, - alias = alias.ifEmpty { null }, - ) +fun UserDisplayItem.toEntity() = UserDisplayEntity( + id = id, + // prevent whitespace before/after name from messing up with matching + targetUser = username.trim(), + enabled = enabled, + colorEnabled = colorEnabled, + color = color, + aliasEnabled = aliasEnabled, + alias = alias.ifEmpty { null }, +) -fun UserDisplayEntity.toItem() = - UserDisplayItem( - id = id, - username = targetUser, - enabled = enabled, - colorEnabled = colorEnabled, - color = color, - aliasEnabled = aliasEnabled, - alias = alias.orEmpty(), - ) +fun UserDisplayEntity.toItem() = UserDisplayItem( + id = id, + username = targetUser, + enabled = enabled, + colorEnabled = colorEnabled, + color = color, + aliasEnabled = aliasEnabled, + alias = alias.orEmpty(), +) val UserDisplayItem.formattedDisplayColor: String get() = "#" + color.hexCode 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 60dea62fc..5ec91bf05 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 @@ -26,13 +26,12 @@ class UserDisplayViewModel( userDisplays.replaceAll(items) } - fun addUserDisplay() = - viewModelScope.launch { - val entity = userDisplayRepository.addUserDisplay() - userDisplays += entity.toItem() - val position = userDisplays.lastIndex - sendEvent(UserDisplayEvent.ItemAdded(position, isLast = true)) - } + fun addUserDisplay() = viewModelScope.launch { + val entity = userDisplayRepository.addUserDisplay() + userDisplays += entity.toItem() + val position = userDisplays.lastIndex + sendEvent(UserDisplayEvent.ItemAdded(position, isLast = true)) + } fun addUserDisplayItem( item: UserDisplayItem, @@ -44,26 +43,23 @@ class UserDisplayViewModel( sendEvent(UserDisplayEvent.ItemAdded(position, isLast)) } - fun removeUserDisplayItem(item: UserDisplayItem) = - viewModelScope.launch { - val position = userDisplays.indexOfFirst { it.id == item.id } - if (position == -1) { - return@launch - } - - userDisplayRepository.removeUserDisplay(item.toEntity()) - userDisplays.removeAt(position) - sendEvent(UserDisplayEvent.ItemRemoved(item, position)) + fun removeUserDisplayItem(item: UserDisplayItem) = viewModelScope.launch { + val position = userDisplays.indexOfFirst { it.id == item.id } + if (position == -1) { + return@launch } - fun updateUserDisplays(userDisplayItems: List) = - viewModelScope.launch { - val entries = userDisplayItems.map(UserDisplayItem::toEntity) - userDisplayRepository.updateUserDisplays(entries) - } + userDisplayRepository.removeUserDisplay(item.toEntity()) + userDisplays.removeAt(position) + sendEvent(UserDisplayEvent.ItemRemoved(item, position)) + } - private suspend fun sendEvent(event: UserDisplayEvent) = - withContext(Dispatchers.Main.immediate) { - eventChannel.send(event) - } + fun updateUserDisplays(userDisplayItems: List) = viewModelScope.launch { + val entries = userDisplayItems.map(UserDisplayItem::toEntity) + userDisplayRepository.updateUserDisplays(entries) + } + + private suspend fun sendEvent(event: UserDisplayEvent) = withContext(Dispatchers.Main.immediate) { + eventChannel.send(event) + } } 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 eba47e7e1..9391b06c2 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 @@ -35,80 +35,79 @@ class DeveloperSettingsViewModel( private val _events = MutableSharedFlow() val events = _events.asSharedFlow() - fun onInteraction(interaction: DeveloperSettingsInteraction) = - viewModelScope.launch { - runCatching { - when (interaction) { - is DeveloperSettingsInteraction.DebugMode -> { - developerSettingsDataStore.update { it.copy(debugMode = interaction.value) } - } + 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.RepeatedSending -> { + developerSettingsDataStore.update { it.copy(repeatedSending = interaction.value) } + } - is DeveloperSettingsInteraction.BypassCommandHandling -> { - developerSettingsDataStore.update { it.copy(bypassCommandHandling = 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 - if (withSlash == developerSettingsDataStore.settings.first().customRecentMessagesHost) return@launch - developerSettingsDataStore.update { it.copy(customRecentMessagesHost = withSlash) } - _events.emit(DeveloperSettingsEvent.RestartRequired) - } + is DeveloperSettingsInteraction.CustomRecentMessagesHost -> { + 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 -> { - developerSettingsDataStore.update { it.copy(eventSubEnabled = interaction.value) } - if (initial.eventSubEnabled != interaction.value) { - _events.emit(DeveloperSettingsEvent.RestartRequired) - } + 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.EventSubDebugOutput -> { + developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } + } - is DeveloperSettingsInteraction.ChatSendProtocolChanged -> { - developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } - } + is DeveloperSettingsInteraction.ChatSendProtocolChanged -> { + developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } + } - is DeveloperSettingsInteraction.RestartRequired -> { - _events.emit(DeveloperSettingsEvent.RestartRequired) - } + is DeveloperSettingsInteraction.RestartRequired -> { + _events.emit(DeveloperSettingsEvent.RestartRequired) + } - is DeveloperSettingsInteraction.ResetOnboarding -> { - onboardingDataStore.update { - it.copy( - hasCompletedOnboarding = false, - onboardingPage = 0, - ) - } - _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.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) - } + 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/notifications/NotificationsSettingsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsViewModel.kt index 88a091455..2f024b170 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 @@ -21,16 +21,15 @@ class NotificationsSettingsViewModel( initialValue = notificationsSettingsDataStore.current(), ) - fun onInteraction(interaction: NotificationsSettingsInteraction) = - viewModelScope.launch { - runCatching { - when (interaction) { - 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) } - } + fun onInteraction(interaction: NotificationsSettingsInteraction) = viewModelScope.launch { + runCatching { + when (interaction) { + 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) } } } + } } sealed interface NotificationsSettingsInteraction { 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 ea7758437..5e70f3cf5 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 @@ -82,94 +82,85 @@ fun MessageHighlightEntity.toItem( customColor = customColor, ) -fun MessageHighlightItem.toEntity() = - MessageHighlightEntity( - id = id, - enabled = enabled, - type = type.toEntityType(), - pattern = pattern, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - createNotification = createNotification, - customColor = customColor, - ) - -fun MessageHighlightItem.Type.toEntityType(): MessageHighlightEntityType = - when (this) { - MessageHighlightItem.Type.Username -> MessageHighlightEntityType.Username - MessageHighlightItem.Type.Subscription -> MessageHighlightEntityType.Subscription - MessageHighlightItem.Type.Announcement -> MessageHighlightEntityType.Announcement - 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 - } +fun MessageHighlightItem.toEntity() = MessageHighlightEntity( + id = id, + enabled = enabled, + type = type.toEntityType(), + pattern = pattern, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + createNotification = createNotification, + customColor = customColor, +) -fun MessageHighlightEntityType.toItemType(): MessageHighlightItem.Type = - when (this) { - MessageHighlightEntityType.Username -> MessageHighlightItem.Type.Username - MessageHighlightEntityType.Subscription -> MessageHighlightItem.Type.Subscription - MessageHighlightEntityType.Announcement -> MessageHighlightItem.Type.Announcement - 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 - } +fun MessageHighlightItem.Type.toEntityType(): MessageHighlightEntityType = when (this) { + MessageHighlightItem.Type.Username -> MessageHighlightEntityType.Username + MessageHighlightItem.Type.Subscription -> MessageHighlightEntityType.Subscription + MessageHighlightItem.Type.Announcement -> MessageHighlightEntityType.Announcement + 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 +} + +fun MessageHighlightEntityType.toItemType(): MessageHighlightItem.Type = when (this) { + MessageHighlightEntityType.Username -> MessageHighlightItem.Type.Username + MessageHighlightEntityType.Subscription -> MessageHighlightItem.Type.Subscription + MessageHighlightEntityType.Announcement -> MessageHighlightItem.Type.Announcement + 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 +} + +fun UserHighlightEntity.toItem(notificationsEnabled: Boolean) = UserHighlightItem( + id = id, + enabled = enabled, + username = username, + createNotification = createNotification, + notificationsEnabled = notificationsEnabled, + customColor = customColor, +) + +fun UserHighlightItem.toEntity() = UserHighlightEntity( + id = id, + enabled = enabled, + username = username, + createNotification = createNotification, + customColor = customColor, +) + +fun BadgeHighlightEntity.toItem(notificationsEnabled: Boolean) = BadgeHighlightItem( + id = id, + enabled = enabled, + badgeName = badgeName, + isCustom = isCustom, + customColor = customColor, + createNotification = createNotification, + notificationsEnabled = notificationsEnabled, +) + +fun BadgeHighlightItem.toEntity() = BadgeHighlightEntity( + id = id, + enabled = enabled, + badgeName = badgeName, + isCustom = isCustom, + customColor = customColor, + createNotification = createNotification, +) + +fun BlacklistedUserEntity.toItem() = BlacklistedUserItem( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, +) -fun UserHighlightEntity.toItem(notificationsEnabled: Boolean) = - UserHighlightItem( - id = id, - enabled = enabled, - username = username, - createNotification = createNotification, - notificationsEnabled = notificationsEnabled, - customColor = customColor, - ) - -fun UserHighlightItem.toEntity() = - UserHighlightEntity( - id = id, - enabled = enabled, - username = username, - createNotification = createNotification, - customColor = customColor, - ) - -fun BadgeHighlightEntity.toItem(notificationsEnabled: Boolean) = - BadgeHighlightItem( - id = id, - enabled = enabled, - badgeName = badgeName, - isCustom = isCustom, - customColor = customColor, - createNotification = createNotification, - notificationsEnabled = notificationsEnabled, - ) - -fun BadgeHighlightItem.toEntity() = - BadgeHighlightEntity( - id = id, - enabled = enabled, - badgeName = badgeName, - isCustom = isCustom, - customColor = customColor, - createNotification = createNotification, - ) - -fun BlacklistedUserEntity.toItem() = - BlacklistedUserItem( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, - ) - -fun BlacklistedUserItem.toEntity() = - BlacklistedUserEntity( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, - ) +fun BlacklistedUserItem.toEntity() = BlacklistedUserEntity( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, +) 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 c231d79f0..b5d13121a 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 @@ -39,53 +39,51 @@ class HighlightsViewModel( _currentTab.value = HighlightsTab.entries[position] } - fun fetchHighlights() = - viewModelScope.launch { - val loggedIn = preferenceStore.isLoggedIn - val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications - val messageHighlightItems = highlightsRepository.messageHighlights.value.map { it.toItem(loggedIn, notificationsEnabled) } - val userHighlightItems = highlightsRepository.userHighlights.value.map { it.toItem(notificationsEnabled) } - val badgeHighlightItems = highlightsRepository.badgeHighlights.value.map { it.toItem(notificationsEnabled) } - val blacklistedUserItems = highlightsRepository.blacklistedUsers.value.map { it.toItem() } - - messageHighlights.replaceAll(messageHighlightItems) - userHighlights.replaceAll(userHighlightItems) - badgeHighlights.replaceAll(badgeHighlightItems) - blacklistedUsers.replaceAll(blacklistedUserItems) - } + fun fetchHighlights() = viewModelScope.launch { + val loggedIn = preferenceStore.isLoggedIn + val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications + val messageHighlightItems = highlightsRepository.messageHighlights.value.map { it.toItem(loggedIn, notificationsEnabled) } + val userHighlightItems = highlightsRepository.userHighlights.value.map { it.toItem(notificationsEnabled) } + val badgeHighlightItems = highlightsRepository.badgeHighlights.value.map { it.toItem(notificationsEnabled) } + val blacklistedUserItems = highlightsRepository.blacklistedUsers.value.map { it.toItem() } + + messageHighlights.replaceAll(messageHighlightItems) + userHighlights.replaceAll(userHighlightItems) + badgeHighlights.replaceAll(badgeHighlightItems) + blacklistedUsers.replaceAll(blacklistedUserItems) + } + + fun addHighlight() = viewModelScope.launch { + val loggedIn = preferenceStore.isLoggedIn + val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications + val position: Int + when (_currentTab.value) { + HighlightsTab.Messages -> { + val entity = highlightsRepository.addMessageHighlight() + messageHighlights += entity.toItem(loggedIn, notificationsEnabled) + position = messageHighlights.lastIndex + } + + HighlightsTab.Users -> { + val entity = highlightsRepository.addUserHighlight() + userHighlights += entity.toItem(notificationsEnabled) + position = userHighlights.lastIndex + } + + HighlightsTab.Badges -> { + val entity = highlightsRepository.addBadgeHighlight() + badgeHighlights += entity.toItem(notificationsEnabled) + position = badgeHighlights.lastIndex + } - fun addHighlight() = - viewModelScope.launch { - val loggedIn = preferenceStore.isLoggedIn - val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications - val position: Int - when (_currentTab.value) { - HighlightsTab.Messages -> { - val entity = highlightsRepository.addMessageHighlight() - messageHighlights += entity.toItem(loggedIn, notificationsEnabled) - position = messageHighlights.lastIndex - } - - HighlightsTab.Users -> { - val entity = highlightsRepository.addUserHighlight() - userHighlights += entity.toItem(notificationsEnabled) - position = userHighlights.lastIndex - } - - HighlightsTab.Badges -> { - val entity = highlightsRepository.addBadgeHighlight() - badgeHighlights += entity.toItem(notificationsEnabled) - position = badgeHighlights.lastIndex - } - - HighlightsTab.BlacklistedUsers -> { - val entity = highlightsRepository.addBlacklistedUser() - blacklistedUsers += entity.toItem() - position = blacklistedUsers.lastIndex - } + HighlightsTab.BlacklistedUsers -> { + val entity = highlightsRepository.addBlacklistedUser() + blacklistedUsers += entity.toItem() + position = blacklistedUsers.lastIndex } - sendEvent(HighlightEvent.ItemAdded(position, isLast = true)) } + sendEvent(HighlightEvent.ItemAdded(position, isLast = true)) + } fun addHighlightItem( item: HighlightItem, @@ -120,36 +118,35 @@ class HighlightsViewModel( sendEvent(HighlightEvent.ItemAdded(position, isLast)) } - fun removeHighlight(item: HighlightItem) = - viewModelScope.launch { - val position: Int - when (item) { - is MessageHighlightItem -> { - position = messageHighlights.indexOfFirst { it.id == item.id } - highlightsRepository.removeMessageHighlight(item.toEntity()) - messageHighlights.removeAt(position) - } - - is UserHighlightItem -> { - position = userHighlights.indexOfFirst { it.id == item.id } - highlightsRepository.removeUserHighlight(item.toEntity()) - userHighlights.removeAt(position) - } - - is BadgeHighlightItem -> { - position = badgeHighlights.indexOfFirst { it.id == item.id } - highlightsRepository.removeBadgeHighlight(item.toEntity()) - badgeHighlights.removeAt(position) - } - - is BlacklistedUserItem -> { - position = blacklistedUsers.indexOfFirst { it.id == item.id } - highlightsRepository.removeBlacklistedUser(item.toEntity()) - blacklistedUsers.removeAt(position) - } + fun removeHighlight(item: HighlightItem) = viewModelScope.launch { + val position: Int + when (item) { + is MessageHighlightItem -> { + position = messageHighlights.indexOfFirst { it.id == item.id } + highlightsRepository.removeMessageHighlight(item.toEntity()) + messageHighlights.removeAt(position) + } + + is UserHighlightItem -> { + position = userHighlights.indexOfFirst { it.id == item.id } + highlightsRepository.removeUserHighlight(item.toEntity()) + userHighlights.removeAt(position) + } + + is BadgeHighlightItem -> { + position = badgeHighlights.indexOfFirst { it.id == item.id } + highlightsRepository.removeBadgeHighlight(item.toEntity()) + badgeHighlights.removeAt(position) + } + + is BlacklistedUserItem -> { + position = blacklistedUsers.indexOfFirst { it.id == item.id } + highlightsRepository.removeBlacklistedUser(item.toEntity()) + blacklistedUsers.removeAt(position) } - sendEvent(HighlightEvent.ItemRemoved(item, position)) } + sendEvent(HighlightEvent.ItemRemoved(item, position)) + } fun updateHighlights( messageHighlightItems: List, @@ -178,30 +175,25 @@ class HighlightsViewModel( } } - private fun filterMessageHighlights(items: List) = - items - .map { it.toEntity() } - .partition { it.type == MessageHighlightEntityType.Custom && it.pattern.isBlank() } - - private fun filterUserHighlights(items: List) = - items - .map { it.toEntity() } - .partition { it.username.isBlank() } - - private fun filterBadgeHighlights(items: List) = - items - .map { it.toEntity() } - .partition { it.badgeName.isBlank() } - - private fun filterBlacklistedUsers(items: List) = - items - .map { it.toEntity() } - .partition { it.username.isBlank() } - - private suspend fun sendEvent(event: HighlightEvent) = - withContext(Dispatchers.Main.immediate) { - eventChannel.send(event) - } + private fun filterMessageHighlights(items: List) = items + .map { it.toEntity() } + .partition { it.type == MessageHighlightEntityType.Custom && it.pattern.isBlank() } + + private fun filterUserHighlights(items: List) = items + .map { it.toEntity() } + .partition { it.username.isBlank() } + + private fun filterBadgeHighlights(items: List) = items + .map { it.toEntity() } + .partition { it.badgeName.isBlank() } + + private fun filterBlacklistedUsers(items: List) = items + .map { it.toEntity() } + .partition { it.username.isBlank() } + + private suspend fun sendEvent(event: HighlightEvent) = withContext(Dispatchers.Main.immediate) { + eventChannel.send(event) + } companion object { const val REGEX_INFO_URL = "https://wiki.chatterino.com/Regex/" 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 5e03c3dcf..a518111a5 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 @@ -45,75 +45,68 @@ data class TwitchBlockItem( val userId: UserId, ) : IgnoreItem -fun MessageIgnoreEntity.toItem() = - MessageIgnoreItem( - id = id, - type = type.toItemType(), - enabled = enabled, - pattern = pattern, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - isBlockMessage = isBlockMessage, - replacement = replacement ?: "", - ) +fun MessageIgnoreEntity.toItem() = MessageIgnoreItem( + id = id, + type = type.toItemType(), + enabled = enabled, + pattern = pattern, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + isBlockMessage = isBlockMessage, + replacement = replacement ?: "", +) -fun MessageIgnoreItem.toEntity() = - MessageIgnoreEntity( - id = id, - type = type.toEntityType(), - enabled = enabled, - pattern = pattern, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - isBlockMessage = isBlockMessage, - replacement = - when { - isBlockMessage -> null - else -> replacement - }, - ) +fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( + id = id, + type = type.toEntityType(), + enabled = enabled, + pattern = pattern, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, + isBlockMessage = isBlockMessage, + 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.ChannelPointRedemption -> MessageIgnoreEntityType.ChannelPointRedemption - MessageIgnoreItem.Type.FirstMessage -> MessageIgnoreEntityType.FirstMessage - MessageIgnoreItem.Type.ElevatedMessage -> MessageIgnoreEntityType.ElevatedMessage - MessageIgnoreItem.Type.Custom -> MessageIgnoreEntityType.Custom - } +fun MessageIgnoreItem.Type.toEntityType(): MessageIgnoreEntityType = when (this) { + MessageIgnoreItem.Type.Subscription -> MessageIgnoreEntityType.Subscription + MessageIgnoreItem.Type.Announcement -> MessageIgnoreEntityType.Announcement + MessageIgnoreItem.Type.ChannelPointRedemption -> MessageIgnoreEntityType.ChannelPointRedemption + 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.ChannelPointRedemption -> MessageIgnoreItem.Type.ChannelPointRedemption - MessageIgnoreEntityType.FirstMessage -> MessageIgnoreItem.Type.FirstMessage - MessageIgnoreEntityType.ElevatedMessage -> MessageIgnoreItem.Type.ElevatedMessage - MessageIgnoreEntityType.Custom -> MessageIgnoreItem.Type.Custom - } +fun MessageIgnoreEntityType.toItemType(): MessageIgnoreItem.Type = when (this) { + MessageIgnoreEntityType.Subscription -> MessageIgnoreItem.Type.Subscription + MessageIgnoreEntityType.Announcement -> MessageIgnoreItem.Type.Announcement + MessageIgnoreEntityType.ChannelPointRedemption -> MessageIgnoreItem.Type.ChannelPointRedemption + MessageIgnoreEntityType.FirstMessage -> MessageIgnoreItem.Type.FirstMessage + MessageIgnoreEntityType.ElevatedMessage -> MessageIgnoreItem.Type.ElevatedMessage + MessageIgnoreEntityType.Custom -> MessageIgnoreItem.Type.Custom +} -fun UserIgnoreEntity.toItem() = - UserIgnoreItem( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - ) +fun UserIgnoreEntity.toItem() = UserIgnoreItem( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, +) -fun UserIgnoreItem.toEntity() = - UserIgnoreEntity( - id = id, - enabled = enabled, - username = username, - isRegex = isRegex, - isCaseSensitive = isCaseSensitive, - ) +fun UserIgnoreItem.toEntity() = UserIgnoreEntity( + id = id, + enabled = enabled, + username = username, + isRegex = isRegex, + isCaseSensitive = isCaseSensitive, +) -fun IgnoresRepository.TwitchBlock.toItem() = - TwitchBlockItem( - id = id.hashCode().toLong(), - userId = id, - username = name, - ) +fun IgnoresRepository.TwitchBlock.toItem() = TwitchBlockItem( + id = id.hashCode().toLong(), + userId = id, + username = name, +) 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 286d93a3f..b24e7c2cd 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 @@ -43,28 +43,27 @@ class IgnoresViewModel( twitchBlocks.replaceAll(twitchBlockItems) } - fun addIgnore() = - viewModelScope.launch { - val position: Int - when (_currentTab.value) { - IgnoresTab.Messages -> { - val entity = ignoresRepository.addMessageIgnore() - messageIgnores += entity.toItem() - position = messageIgnores.lastIndex - } + fun addIgnore() = viewModelScope.launch { + val position: Int + when (_currentTab.value) { + IgnoresTab.Messages -> { + val entity = ignoresRepository.addMessageIgnore() + messageIgnores += entity.toItem() + position = messageIgnores.lastIndex + } - IgnoresTab.Users -> { - val entity = ignoresRepository.addUserIgnore() - userIgnores += entity.toItem() - position = userIgnores.lastIndex - } + 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)) } + sendEvent(IgnoreEvent.ItemAdded(position, isLast = true)) + } fun addIgnoreItem( item: IgnoreItem, @@ -98,35 +97,34 @@ class IgnoresViewModel( sendEvent(IgnoreEvent.ItemAdded(position, isLast)) } - fun removeIgnore(item: IgnoreItem) = - viewModelScope.launch { - val position: Int - when (item) { - is MessageIgnoreItem -> { - position = messageIgnores.indexOfFirst { it.id == item.id } - ignoresRepository.removeMessageIgnore(item.toEntity()) - messageIgnores.removeAt(position) - } + fun removeIgnore(item: IgnoreItem) = viewModelScope.launch { + val position: Int + when (item) { + is MessageIgnoreItem -> { + position = messageIgnores.indexOfFirst { it.id == item.id } + ignoresRepository.removeMessageIgnore(item.toEntity()) + messageIgnores.removeAt(position) + } - is UserIgnoreItem -> { - position = userIgnores.indexOfFirst { it.id == item.id } - ignoresRepository.removeUserIgnore(item.toEntity()) - userIgnores.removeAt(position) - } + is UserIgnoreItem -> { + position = userIgnores.indexOfFirst { it.id == item.id } + ignoresRepository.removeUserIgnore(item.toEntity()) + userIgnores.removeAt(position) + } - 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 - } + 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 } } - sendEvent(IgnoreEvent.ItemRemoved(item, position)) } + sendEvent(IgnoreEvent.ItemRemoved(item, position)) + } fun updateIgnores( messageIgnoreItems: List, @@ -143,20 +141,17 @@ class IgnoresViewModel( } } - private fun filterMessageIgnores(items: List) = - items - .map { it.toEntity() } - .partition { it.type == MessageIgnoreEntityType.Custom && it.pattern.isBlank() } + private fun filterMessageIgnores(items: List) = items + .map { it.toEntity() } + .partition { it.type == MessageIgnoreEntityType.Custom && it.pattern.isBlank() } - private fun filterUserIgnores(items: List) = - items - .map { it.toEntity() } - .partition { it.username.isBlank() } + private fun filterUserIgnores(items: List) = items + .map { it.toEntity() } + .partition { it.username.isBlank() } - private suspend fun sendEvent(event: IgnoreEvent) = - withContext(Dispatchers.Main.immediate) { - eventChannel.send(event) - } + private suspend fun sendEvent(event: IgnoreEvent) = withContext(Dispatchers.Main.immediate) { + eventChannel.send(event) + } companion object { const val REGEX_INFO_URL = "https://wiki.chatterino.com/Regex/" 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 2106789e1..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 @@ -40,13 +40,12 @@ fun SecretDankerModeTrigger(content: @Composable SecretDankerScope.() -> Unit) { val scope = remember { object : SecretDankerScope { - override fun Modifier.dankClickable() = - clickable( - enabled = !secretDankerMode, - onClick = { currentClicks++ }, - interactionSource = null, - indication = null, - ) + override fun Modifier.dankClickable() = clickable( + enabled = !secretDankerMode, + onClick = { currentClicks++ }, + interactionSource = null, + indication = null, + ) } } val context = LocalContext.current 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 282305e82..da3d27a88 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 @@ -20,18 +20,17 @@ class StreamsSettingsViewModel( 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.PreventStreamReloads -> dataStore.update { it.copy(preventStreamReloads = interaction.value) } - is StreamsSettingsInteraction.EnablePiP -> dataStore.update { it.copy(enablePiP = interaction.value) } - } + 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.PreventStreamReloads -> dataStore.update { it.copy(preventStreamReloads = interaction.value) } + is StreamsSettingsInteraction.EnablePiP -> dataStore.update { it.copy(enablePiP = interaction.value) } } } + } } sealed interface StreamsSettingsInteraction { 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 551bb3ec7..7283e3930 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 @@ -29,31 +29,29 @@ class ToolsSettingsViewModel( 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.TTSUserIgnoreList -> toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = interaction.value) } - } + 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.TTSUserIgnoreList -> toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = interaction.value) } } } + } } -private fun ToolsSettings.toState(hasRecentUploads: Boolean) = - ToolsSettingsState( - imageUploader = uploaderConfig, - hasRecentUploads = hasRecentUploads, - ttsEnabled = ttsEnabled, - ttsPlayMode = ttsPlayMode, - ttsMessageFormat = ttsMessageFormat, - ttsForceEnglish = ttsForceEnglish, - ttsIgnoreUrls = ttsIgnoreUrls, - ttsIgnoreEmotes = ttsIgnoreEmotes, - ttsUserIgnoreList = ttsUserIgnoreList.toImmutableSet(), - ) +private fun ToolsSettings.toState(hasRecentUploads: Boolean) = ToolsSettingsState( + imageUploader = uploaderConfig, + hasRecentUploads = hasRecentUploads, + ttsEnabled = ttsEnabled, + ttsPlayMode = ttsPlayMode, + ttsMessageFormat = ttsMessageFormat, + ttsForceEnglish = ttsForceEnglish, + ttsIgnoreUrls = ttsIgnoreUrls, + ttsIgnoreEmotes = ttsIgnoreEmotes, + ttsUserIgnoreList = ttsUserIgnoreList.toImmutableSet(), +) 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 b7e49b229..1d86710db 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 @@ -26,11 +26,10 @@ class TTSUserIgnoreListViewModel( initialValue = toolsSettingsDataStore.current().ttsUserIgnoreList.mapToUserIgnores(), ) - fun save(ignores: List) = - viewModelScope.launch { - val filtered = ignores.mapNotNullTo(mutableSetOf()) { it.user.takeIf { it.isNotBlank() } } - toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = filtered) } - } + fun save(ignores: List) = viewModelScope.launch { + val filtered = ignores.mapNotNullTo(mutableSetOf()) { it.user.takeIf { it.isNotBlank() } } + toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = filtered) } + } private fun Set.mapToUserIgnores() = map { UserIgnore(user = it) }.toImmutableList() } 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 3425c55e3..d4083ea98 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 @@ -23,19 +23,17 @@ class ImageUploaderViewModel( 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() }, - ) - toolsSettingsDataStore.update { it.copy(uploaderConfig = validated) } - } + 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() }, + ) + toolsSettingsDataStore.update { it.copy(uploaderConfig = validated) } + } - fun reset() = - viewModelScope.launch { - toolsSettingsDataStore.update { it.copy(uploaderConfig = ImageUploaderConfig.DEFAULT) } - } + fun reset() = viewModelScope.launch { + toolsSettingsDataStore.update { it.copy(uploaderConfig = ImageUploaderConfig.DEFAULT) } + } } 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 ecaf2c2d6..b3a9c9976 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 @@ -38,10 +38,9 @@ class RecentUploadsViewModel( initialValue = emptyList(), ) - fun clearUploads() = - viewModelScope.launch { - recentUploadsRepository.clearUploads() - } + fun clearUploads() = viewModelScope.launch { + recentUploadsRepository.clearUploads() + } companion object { private val formatter = @@ -49,9 +48,8 @@ class RecentUploadsViewModel( .ofLocalizedDateTime(FormatStyle.SHORT) .withZone(ZoneId.systemDefault()) - private fun Instant.formatWithLocale(locale: Locale) = - formatter - .withLocale(locale) - .format(this) + private fun Instant.formatWithLocale(locale: Locale) = formatter + .withLocale(locale) + .format(this) } } 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 index b5c1a9fcf..71c593d06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt @@ -19,12 +19,11 @@ data class DankChatVersion( .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) } + 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/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt index 31e20dddf..41a80cbdd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -734,57 +734,55 @@ class ChatMessageMapper( private fun calculateCheckeredBackgroundColors( isAlternateBackground: Boolean, enableCheckered: Boolean, - ): BackgroundColors = - if (enableCheckered && isAlternateBackground) { - BackgroundColors(CHECKERED_LIGHT, CHECKERED_DARK) - } else { - BackgroundColors(Color.Transparent, Color.Transparent) - } + ): 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, - ) - } + private fun getHighlightColors(type: HighlightType): BackgroundColors = when (type) { + HighlightType.Subscription, + HighlightType.Announcement, + -> { + BackgroundColors( + light = COLOR_SUB_HIGHLIGHT_LIGHT, + dark = COLOR_SUB_HIGHLIGHT_DARK, + ) + } - HighlightType.ChannelPointRedemption -> { - BackgroundColors( - light = COLOR_REDEMPTION_HIGHLIGHT_LIGHT, - dark = COLOR_REDEMPTION_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.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.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, - ) - } + 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 = @@ -818,14 +816,13 @@ class ChatMessageMapper( fun defaultHighlightColorInt( type: HighlightType, isDark: Boolean, - ): Int = - when (type) { - HighlightType.Subscription, HighlightType.Announcement -> if (isDark) 0xFF6A45A0 else 0xFF7E57C2 - 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() + ): Int = when (type) { + HighlightType.Subscription, HighlightType.Announcement -> if (isDark) 0xFF6A45A0 else 0xFF7E57C2 + 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( 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 index 44faca226..bd67ef72b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -215,9 +215,8 @@ data class ThreadUi( val message: String, ) -fun MessageThreadHeader.toThreadUi(): ThreadUi = - ThreadUi( - rootId = rootId, - userName = name.value, - message = message, - ) +fun MessageThreadHeader.toThreadUi(): ThreadUi = ThreadUi( + rootId = rootId, + userName = name.value, + message = message, +) 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 index 20db5d1aa..dfbcf0062 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -600,57 +600,56 @@ private fun getFabMenuItem( isFullscreen: Boolean, isModerator: Boolean, debugMode: Boolean, -): FabMenuItem? = - when (action) { - InputAction.Search -> { - FabMenuItem(R.string.input_action_search, Icons.Default.Search) - } - - InputAction.LastMessage -> { - FabMenuItem(R.string.input_action_last_message, Icons.Default.History) - } +): FabMenuItem? = when (action) { + InputAction.Search -> { + FabMenuItem(R.string.input_action_search, Icons.Default.Search) + } - 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, - ) - } + InputAction.LastMessage -> { + FabMenuItem(R.string.input_action_last_message, Icons.Default.History) + } - else -> { - 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, + ) } - } - InputAction.ModActions -> { - when { - isModerator -> FabMenuItem(R.string.menu_mod_actions, Icons.Default.Shield) - else -> null + 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.ModActions -> { + when { + isModerator -> FabMenuItem(R.string.menu_mod_actions, Icons.Default.Shield) + else -> null } + } - InputAction.HideInput -> { - 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, + ) + } - // Already hidden, no point showing this - InputAction.Debug -> { - when { - debugMode -> FabMenuItem(R.string.input_action_debug, Icons.Default.BugReport) - else -> null - } + InputAction.HideInput -> { + null + } + + // Already hidden, no point showing this + InputAction.Debug -> { + when { + debugMode -> FabMenuItem(R.string.input_action_debug, Icons.Default.BugReport) + else -> null } } +} private val HIGHLIGHT_CORNER_RADIUS = 8.dp 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 index 2148bc4b7..b76442e7d 100644 --- 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 @@ -28,53 +28,49 @@ class EmoteInfoViewModel( ) }.toImmutableList() - private fun ChatMessageEmote.baseNameOrNull(): String? = - when (type) { - is ChatMessageEmoteType.GlobalSevenTVEmote -> type.baseName - is ChatMessageEmoteType.ChannelSevenTVEmote -> type.baseName - else -> null - } + 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.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" + 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.ChannelBTTVEmote, + is ChatMessageEmoteType.GlobalBTTVEmote, + -> "$BTTV_BASE_LINK$id" - is ChatMessageEmoteType.ChannelFFZEmote, - is ChatMessageEmoteType.GlobalFFZEmote, - -> "$FFZ_BASE_LINK$id-$code" + is ChatMessageEmoteType.ChannelFFZEmote, + is ChatMessageEmoteType.GlobalFFZEmote, + -> "$FFZ_BASE_LINK$id-$code" - is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" + is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" - is ChatMessageEmoteType.Cheermote -> "$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 - } + 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/" 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 index c0d80a797..5e6a3e532 100644 --- 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 @@ -267,15 +267,14 @@ private fun transformEmoteDrawable( 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) +): 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) - } + // 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 index bf12108b3..f1e6578df 100644 --- 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 @@ -9,11 +9,10 @@ sealed class EmoteItem { 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 - } + 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( 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 index 44f5b41e4..f8ad0661f 100644 --- 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 @@ -75,29 +75,25 @@ class MessageOptionsViewModel( } }.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 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 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 unbanUser() = viewModelScope.launch { + val name = (state.value as? MessageOptionsState.Found)?.name ?: return@launch + sendCommand(".unban $name") + } - fun deleteMessage() = - viewModelScope.launch { - sendCommand(".delete $messageId") - } + fun deleteMessage() = viewModelScope.launch { + sendCommand(".delete $messageId") + } private suspend fun sendCommand(message: String) { val activeChannel = channel ?: return 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 index cf2b9f2c6..4d47befb4 100644 --- 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 @@ -28,65 +28,60 @@ object ChatItemFilter { 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 - } + ): 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) - } - - else -> { - false - } + ): Boolean = when (val message = item.message) { + is PrivMessage -> { + message.name.value.equals(name, ignoreCase = true) || + message.displayName.value.equals(name, ignoreCase = true) } - private fun matchLink(item: ChatItem): Boolean = - when (val message = item.message) { - is PrivMessage -> URL_REGEX.containsMatchIn(message.message) - else -> false + 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) } - } + ): 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 - } + 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 - } + ): 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 - } + else -> { + false } + } } 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 index 82f0e9cf0..94fff1337 100644 --- 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 @@ -63,9 +63,8 @@ object ChatSearchFilterParser { 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 - } + 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/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt index 7b5fc0875..b0a3981cc 100644 --- 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 @@ -85,38 +85,34 @@ class SuggestionProvider( private fun getScoredEmoteSuggestions( channel: UserName, constraint: String, - ): Flow> = - emoteRepository.getEmotes(channel).map { emotes -> - val recentIds = emoteUsageRepository.recentEmoteIds.value - filterEmotesScored(emotes.suggestions, constraint, recentIds) - } + ): 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) - } + ): 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) - } + ): Flow> = usersRepository.getUsersFlow(channel).map { displayNameSet -> + filterUsers(displayNameSet, constraint) + } private fun getCommandSuggestions( channel: UserName, constraint: String, - ): Flow> = - combine( - commandRepository.getCommandTriggers(channel), - commandRepository.getSupibotCommands(channel), - ) { triggers, supibotCommands -> - filterCommands(triggers + supibotCommands, constraint) - } + ): Flow> = combine( + commandRepository.getCommandTriggers(channel), + commandRepository.getSupibotCommands(channel), + ) { triggers, supibotCommands -> + filterCommands(triggers + supibotCommands, constraint) + } // Merge two pre-sorted lists in O(n+m) without intermediate allocations private fun mergeSorted( @@ -177,23 +173,21 @@ class SuggestionProvider( 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 } + ): 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 } + ): 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( @@ -211,22 +205,20 @@ class SuggestionProvider( 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 } + ): 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) } + ): 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt index 56456a3dc..b467a979d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt @@ -37,65 +37,61 @@ class UserPopupViewModel( loadData() } - fun blockUser() = - updateStateWith { targetUserId, targetUsername -> - ignoresRepository.addUserBlock(targetUserId, targetUsername) - } + fun blockUser() = updateStateWith { targetUserId, targetUsername -> + ignoresRepository.addUserBlock(targetUserId, targetUsername) + } - fun unblockUser() = - updateStateWith { targetUserId, targetUsername -> - ignoresRepository.removeUserBlock(targetUserId, targetUsername) - } + fun unblockUser() = updateStateWith { targetUserId, targetUsername -> + ignoresRepository.removeUserBlock(targetUserId, targetUsername) + } - private inline fun updateStateWith(crossinline block: suspend (targetUserId: UserId, targetUsername: UserName) -> Unit) = - viewModelScope.launch { - if (!preferenceStore.isLoggedIn) { - return@launch - } + private inline fun updateStateWith(crossinline block: suspend (targetUserId: UserId, targetUsername: UserName) -> Unit) = viewModelScope.launch { + if (!preferenceStore.isLoggedIn) { + return@launch + } - val result = runCatching { block(params.targetUserId, params.targetUserName) } - when { - result.isFailure -> _userPopupState.value = UserPopupState.Error(result.exceptionOrNull()) - else -> loadData() - } + val result = runCatching { block(params.targetUserId, params.targetUserName) } + when { + result.isFailure -> _userPopupState.value = UserPopupState.Error(result.exceptionOrNull()) + else -> loadData() } + } - private fun loadData() = - viewModelScope.launch { - _userPopupState.value = UserPopupState.Loading(params.targetUserName, params.targetDisplayName) - val currentUserId = preferenceStore.userIdString - if (!preferenceStore.isLoggedIn || currentUserId == null) { - _userPopupState.value = UserPopupState.Error() - return@launch - } + private fun loadData() = viewModelScope.launch { + _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 = 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 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) - } + 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 - } + val state = result.getOrElse { UserPopupState.Error(it) } + _userPopupState.value = state + } private fun mapToState( user: UserDto?, 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 index d140f2535..3cd9151ff 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt @@ -25,28 +25,27 @@ class LoginViewModel( val loginUrl = AuthApiClient.LOGIN_URL - fun parseToken(fragment: String) = - viewModelScope.launch { - if (!fragment.startsWith("access_token")) { - eventChannel.send(TokenParseEvent(successful = false)) - return@launch - } + 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 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) - } + 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 suspend fun saveLoginDetails( oAuth: String, 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 index 007b9ad91..782997efb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -649,83 +649,81 @@ fun FloatingToolbar( * 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) - } +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.minIntrinsicWidth( + measurable: IntrinsicMeasurable, + height: Int, + ): Int = 0 - override fun IntrinsicMeasureScope.maxIntrinsicWidth( - 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.minIntrinsicHeight( + measurable: IntrinsicMeasurable, + width: Int, + ): Int = measurable.minIntrinsicHeight(width) - override fun IntrinsicMeasureScope.maxIntrinsicHeight( - measurable: IntrinsicMeasurable, - width: Int, - ): Int = measurable.maxIntrinsicHeight(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) - } +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.minIntrinsicHeight( + measurable: IntrinsicMeasurable, + width: Int, + ): Int = 0 - override fun IntrinsicMeasureScope.maxIntrinsicHeight( - 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.minIntrinsicWidth( + measurable: IntrinsicMeasurable, + height: Int, + ): Int = measurable.minIntrinsicWidth(height) - override fun IntrinsicMeasureScope.maxIntrinsicWidth( - measurable: IntrinsicMeasurable, - height: Int, - ): Int = measurable.maxIntrinsicWidth(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/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt index 303c676ad..e2ca5e260 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -543,11 +543,10 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channelExtra)) } } - fun clearNotificationsOfChannel(channel: UserName) = - when { - isBound && notificationService != null -> notificationService?.setActiveChannel(channel) - else -> pendingChannelsToClear += channel - } + fun clearNotificationsOfChannel(channel: UserName) = when { + isBound && notificationService != null -> notificationService?.setActiveChannel(channel) + else -> pendingChannelsToClear += channel + } private fun handleShutDown() { stopService(Intent(this, NotificationService::class.java)) 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 index caa33410a..6d2dbd707 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -160,84 +160,82 @@ private fun getOverflowItem( hasStreamData: Boolean, isFullscreen: Boolean, isModerator: Boolean, -): OverflowItem? = - when (action) { - InputAction.Search -> { - OverflowItem( - labelRes = R.string.input_action_search, - icon = Icons.Default.Search, - ) - } +): 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.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.Default.VideocamOff else Icons.Default.Videocam, - ) - } + InputAction.Stream -> { + when { + hasStreamData || isStreamActive -> { + OverflowItem( + labelRes = if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream, + icon = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + ) + } - else -> { - null - } + else -> { + null } } + } - InputAction.ModActions -> { - when { - isModerator -> { - OverflowItem( - labelRes = R.string.menu_mod_actions, - icon = Icons.Default.Shield, - ) - } + InputAction.ModActions -> { + when { + isModerator -> { + OverflowItem( + labelRes = R.string.menu_mod_actions, + icon = Icons.Default.Shield, + ) + } - else -> { - null - } + 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.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.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, - ) - } + 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 - } +): 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. 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 index 9f5f7565e..ab76fae49 100644 --- 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 @@ -114,18 +114,17 @@ class ChannelManagementViewModel( 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 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) 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 index 8b1210d13..8cfaaca1c 100644 --- 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 @@ -100,15 +100,14 @@ private val FOLLOWER_MODE_PRESETS = ) @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) - } +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 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 index 3b4ae17e1..107383514 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt @@ -26,13 +26,12 @@ class OnboardingDataStore( 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 migrate(currentData: OnboardingSettings): OnboardingSettings = currentData.copy( + hasCompletedOnboarding = true, + hasRunExistingUserMigration = true, + hasShownAddChannelHint = true, + hasShownToolbarHint = true, + ) override suspend fun cleanUp() = Unit } 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 index bb60b18a5..5fefe4e3f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt @@ -234,14 +234,13 @@ class FeatureTourViewModel( 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 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, @@ -251,26 +250,25 @@ class FeatureTourViewModel( tourActive: Boolean, tourCompleted: Boolean, authValidated: Boolean, - ): PostOnboardingStep = - when { - tourCompleted -> PostOnboardingStep.Complete + ): PostOnboardingStep = when { + tourCompleted -> PostOnboardingStep.Complete - settings.featureTourVersion >= CURRENT_TOUR_VERSION && toolbarHintDone -> PostOnboardingStep.Complete + settings.featureTourVersion >= CURRENT_TOUR_VERSION && toolbarHintDone -> PostOnboardingStep.Complete - !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle + !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle - !authValidated -> PostOnboardingStep.Idle + !authValidated -> PostOnboardingStep.Idle - !channelReady -> PostOnboardingStep.Idle + !channelReady -> PostOnboardingStep.Idle - channelEmpty -> PostOnboardingStep.Idle + channelEmpty -> PostOnboardingStep.Idle - tourActive -> PostOnboardingStep.FeatureTour + tourActive -> PostOnboardingStep.FeatureTour - !toolbarHintDone -> PostOnboardingStep.ToolbarPlusHint + !toolbarHintDone -> PostOnboardingStep.ToolbarPlusHint - // At this point: toolbarHintDone=true but (version >= CURRENT && toolbarHintDone) was false, - // so featureTourVersion < CURRENT_TOUR_VERSION is guaranteed. - else -> PostOnboardingStep.FeatureTour - } + // 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/DateTimeUtils.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt index f9616d8d9..9d1fc0c66 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt @@ -10,16 +10,14 @@ object DateTimeUtils { fun timestampToLocalTime( ts: Long, formatter: DateTimeFormatter, - ): String = - Instant - .ofEpochMilli(ts) - .atZone(ZoneId.systemDefault()) - .format(formatter) + ): String = Instant + .ofEpochMilli(ts) + .atZone(ZoneId.systemDefault()) + .format(formatter) - fun String.asParsedZonedDateTime(): String = - ZonedDateTime - .parse(this) - .format(DateTimeFormatter.ISO_LOCAL_DATE) + fun String.asParsedZonedDateTime(): String = ZonedDateTime + .parse(this) + .format(DateTimeFormatter.ISO_LOCAL_DATE) fun formatSeconds(durationInSeconds: Int): String { val seconds = durationInSeconds % 60 @@ -69,15 +67,14 @@ object DateTimeUtils { return seconds } - private fun secondsMultiplierForUnit(char: Char): Int? = - when (char) { - 's' -> 1 - 'm' -> 60 - 'h' -> 60 * 60 - 'd' -> 60 * 60 * 24 - 'w' -> 60 * 60 * 24 * 7 - else -> null - } + private fun secondsMultiplierForUnit(char: Char): Int? = when (char) { + '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 } @@ -86,28 +83,26 @@ object DateTimeUtils { 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 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 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() 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 88669471b..9b7031b4f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt @@ -10,18 +10,16 @@ class GetImageOrVideoContract : ActivityResultContract() { 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/*")) + ): 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 { - intent == null || resultCode != Activity.RESULT_OK -> null - else -> intent.data - } + ): Uri? = when { + intent == null || resultCode != Activity.RESULT_OK -> null + else -> intent.data + } } 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 4e8de1bc0..55af2be06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt @@ -56,19 +56,17 @@ fun createMediaFile( return File.createTempFile(timeStamp, ".$suffix", storageDir) } -fun tryClearEmptyFiles(context: Context) = - runCatching { - val cutoff = System.currentTimeMillis().milliseconds - 1.days - context - .getExternalFilesDir("Media") - ?.listFiles() - ?.filter { it.isFile && it.lastModified().milliseconds < cutoff } - ?.onEach { it.delete() } - } +fun tryClearEmptyFiles(context: Context) = runCatching { + val cutoff = System.currentTimeMillis().milliseconds - 1.days + context + .getExternalFilesDir("Media") + ?.listFiles() + ?.filter { it.isFile && it.lastModified().milliseconds < cutoff } + ?.onEach { it.delete() } +} @Throws(IOException::class, IllegalStateException::class) -fun File.removeExifAttributes() = - ExifInterface(this).run { - GPS_ATTRIBUTES.forEach { if (getAttribute(it) != null) setAttribute(it, null) } - saveAttributes() - } +fun File.removeExifAttributes() = ExifInterface(this).run { + GPS_ATTRIBUTES.forEach { if (getAttribute(it) != null) setAttribute(it, null) } + saveAttributes() +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt index 4203b56b2..a13bdb194 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt @@ -31,31 +31,30 @@ sealed interface TextResource { } @Composable -fun TextResource.resolve(): String = - when (this) { - is TextResource.Plain -> { - value - } +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 - } + is TextResource.Res -> { + val resolvedArgs = + args.map { arg -> + when (arg) { + is TextResource -> arg.resolve() + else -> arg } - stringResource(id, *resolvedArgs.toTypedArray()) - } + } + stringResource(id, *resolvedArgs.toTypedArray()) + } - is TextResource.PluralRes -> { - val resolvedArgs = - args.map { arg -> - when (arg) { - is TextResource -> arg.resolve() - else -> arg - } + is TextResource.PluralRes -> { + val resolvedArgs = + args.map { arg -> + when (arg) { + is TextResource -> arg.resolve() + else -> arg } - pluralStringResource(id, quantity, *resolvedArgs.toTypedArray()) - } + } + 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 index fd0dca841..8d0c06f39 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt @@ -20,11 +20,10 @@ object BottomSheetNestedScrollConnection : NestedScrollConnection { consumed: Offset, available: Offset, source: NestedScrollSource, - ): Offset = - when (source) { - NestedScrollSource.SideEffect -> available.copy(x = 0f) - else -> Offset.Zero - } + ): Offset = when (source) { + NestedScrollSource.SideEffect -> available.copy(x = 0f) + else -> Offset.Zero + } override suspend fun onPostFling( consumed: Velocity, 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 index 198e50d0b..8f3d8cfcc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/PredictiveBackModifier.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/PredictiveBackModifier.kt @@ -3,10 +3,9 @@ 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) - } +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 index 3b672b61a..720004dd1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -36,79 +36,78 @@ import kotlin.math.sin * 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, - ) +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 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 9da615869..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,35 +9,32 @@ import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextDecoration @Composable -fun textLinkStyles(): TextLinkStyles = - TextLinkStyles( - style = - SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - pressedStyle = - SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - background = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.medium), - ), - ) +fun textLinkStyles(): TextLinkStyles = TextLinkStyles( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + 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( - url = url, - styles = textLinkStyles(), - ) +fun buildLinkAnnotation(url: String): LinkAnnotation = LinkAnnotation.Url( + url = url, + styles = textLinkStyles(), +) @Composable fun buildClickableAnnotation( text: String, onClick: LinkInteractionListener, -): LinkAnnotation = - LinkAnnotation.Clickable( - tag = text, - styles = textLinkStyles(), - linkInteractionListener = onClick, - ) +): 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 a29b65e08..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 @@ -22,10 +22,9 @@ class DataStoreKotlinxSerializer( } } - override suspend fun readFrom(source: BufferedSource): T = - runCatching { - json.decodeFromBufferedSource(serializer, source) - }.getOrDefault(defaultValue) + override suspend fun readFrom(source: BufferedSource): T = runCatching { + json.decodeFromBufferedSource(serializer, source) + }.getOrDefault(defaultValue) override suspend fun writeTo( t: T, 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 dc8ed8a35..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 @@ -40,10 +40,9 @@ fun createDataStore( migrations = migrations, ) -inline fun DataStore.safeData(defaultValue: T): Flow = - data.catch { e -> - when (e) { - is IOException -> emit(defaultValue) - else -> throw e - } +inline fun DataStore.safeData(defaultValue: T): Flow = data.catch { e -> + when (e) { + is IOException -> emit(defaultValue) + 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 3343c700b..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 @@ -17,37 +17,35 @@ inline fun dankChatPreferencesMigration( context: Context, prefs: SharedPreferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE), crossinline migrateValue: suspend (currentData: T, key: K, value: Any?) -> T, -): DataMigration where K : Enum, K : PreferenceKeys = - dankChatMigration( - context = context, - prefs = prefs, - keyMapper = { context.getString(it.id) }, - migrateValue = migrateValue, - ) +): DataMigration where K : Enum, K : PreferenceKeys = dankChatMigration( + context = context, + prefs = prefs, + keyMapper = { context.getString(it.id) }, + migrateValue = migrateValue, +) inline fun dankChatMigration( context: Context, prefs: SharedPreferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE), crossinline keyMapper: (K) -> String, 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) -> - val mapped = map[key] ?: return@fold acc - migrateValue(acc, mapped, value) - } - }.getOrDefault(currentData) - } - - override suspend fun shouldMigrate(currentData: T): Boolean = map.keys.any(prefs::contains) - - override suspend fun cleanUp() = prefs.edit { map.keys.forEach(::remove) } +): 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) -> + val mapped = map[key] ?: return@fold acc + migrateValue(acc, mapped, value) + } + }.getOrDefault(currentData) } + 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 @@ -76,7 +74,6 @@ fun > Any?.mappedStringSetOrDefault( original: Array, enumEntries: EnumEntries, default: List, -): List = - stringSetOrNull()?.toList()?.mapNotNull { - enumEntries.getOrNull(original.indexOf(it)) - } ?: default +): List = stringSetOrNull()?.toList()?.mapNotNull { + enumEntries.getOrNull(original.indexOf(it)) +} ?: default 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 index 6f42069a4..1ec338556 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt @@ -6,51 +6,49 @@ fun List.addAndLimit( item: ChatItem, scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit, -): List = - toMutableList().apply { - add(item) - while (size > scrollBackLength) { - onMessageRemoved(removeAt(index = 0)) - } +): 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 - } +): 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)) - } + 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( 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 edbecffa0..65b577f92 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 @@ -11,33 +11,31 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield import kotlin.time.Duration -suspend fun Collection.concurrentMap(block: suspend (T) -> R): List = - coroutineScope { - map { async { block(it) } }.awaitAll() - } +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 = - launch { - val scope = TimerScope() - - while (true) { - try { - action(scope) - } catch (ex: Exception) { - Log.e("TimerScope", Log.getStackTraceString(ex)) - } - - if (scope.isCancelled) { - break - } - - delay(interval) - yield() +): Job = launch { + val scope = TimerScope() + + while (true) { + try { + action(scope) + } catch (ex: Exception) { + Log.e("TimerScope", Log.getStackTraceString(ex)) + } + + if (scope.isCancelled) { + break } + + delay(interval) + yield() } +} class TimerScope { var isCancelled: Boolean = false 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 0301bd599..c885d09f2 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 @@ -10,13 +10,12 @@ import com.flxrs.dankchat.data.twitch.emote.GenericEmote import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem 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?.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() @@ -38,10 +37,9 @@ fun List?.toEmoteItemsWithFront(channel: UserName?): List.moveToFront(channel: UserName?): List = - this - .partition { it.emoteType.title.equals(channel?.value, ignoreCase = true) } - .run { first + second } +fun List.moveToFront(channel: UserName?): List = this + .partition { it.emoteType.title.equals(channel?.value, ignoreCase = true) } + .run { first + second } inline fun measureTimeValue(block: () -> V): Pair { val start = System.currentTimeMillis() @@ -62,10 +60,9 @@ inline fun measureTimeAndLog( return result } -inline fun Json.decodeOrNull(json: String): T? = - runCatching { - decodeFromString(json) - }.getOrNull() +inline fun Json.decodeOrNull(json: String): T? = runCatching { + decodeFromString(json) +}.getOrNull() val Int.isEven get() = (this % 2 == 0) 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 4f4c0f467..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 @@ -14,21 +14,19 @@ fun mutableSharedFlowOf( replayValue: Int = 1, extraBufferCapacity: Int = 0, onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST, -): MutableSharedFlow = - MutableSharedFlow(replayValue, extraBufferCapacity, onBufferOverflow).apply { - tryEmit(defaultValue) - } +): 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)) - } +): Flow = transformLatest { + when (it) { + null -> emit(defaultValue) + else -> emitAll(transform(it)) } +} inline val SharedFlow.firstValue: T get() = replayCache.first() @@ -46,12 +44,11 @@ fun MutableSharedFlow>.increment( }, ) -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, @@ -61,18 +58,17 @@ fun combine( 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, - ) - } +): 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, 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 index 317eea2f3..75b35d7a2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -21,79 +21,77 @@ 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 = 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) - } - } +): List = toMutableList().apply { + if (!moderationMessage.canClearMessages) { + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) + return this + } - ModerationMessage.Action.Timeout, - ModerationMessage.Action.Ban, - ModerationMessage.Action.SharedTimeout, - ModerationMessage.Action.SharedBan, - -> { - item.message as? PrivMessage ?: continue - if (moderationMessage.targetUser != item.message.name) { - continue + 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) } + } - this[idx] = item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) - } - - else -> { + 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) } - } - if (addSystemMessage) { - addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) + 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 == 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) { +): 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] - 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 + 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 } } + } - addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) + 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) +} + private fun MutableList.checkForStackedTimeouts(moderationMessage: ModerationMessage): Boolean { if (moderationMessage.canStack) { val end = (lastIndex - 20).coerceAtLeast(0) 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 90a11dc9b..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 @@ -215,8 +215,7 @@ inline fun CharSequence.indexOfFirst( return -1 } -fun String.truncate(maxLength: Int = 120) = - when { - length <= maxLength -> this - else -> take(maxLength) + Typography.ellipsis - } +fun String.truncate(maxLength: Int = 120) = when { + length <= maxLength -> this + 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 index 8be1fc287..a8cb7cdb3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt @@ -10,11 +10,10 @@ fun List.addSystemMessage( scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit, onReconnect: () -> Unit = {}, -): List = - when { - type != SystemMessageType.Connected -> addAndLimit(type.toChatItem(), scrollBackLength, onMessageRemoved) - else -> replaceLastSystemMessageIfNecessary(scrollBackLength, onMessageRemoved, onReconnect) - } +): List = when { + type != SystemMessageType.Connected -> addAndLimit(type.toChatItem(), scrollBackLength, onMessageRemoved) + else -> replaceLastSystemMessageIfNecessary(scrollBackLength, onMessageRemoved, onReconnect) +} private fun List.replaceLastSystemMessageIfNecessary( scrollBackLength: Int, 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 index b23f123de..6fd40a526 100644 --- 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 @@ -64,166 +64,156 @@ internal class ChatConnectionTest { } @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]) - } + 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]) - } + 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) - } + 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") - } + 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") - } + 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") - } + 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) - } + 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") - } + 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) - } + 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") - } + 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, diff --git a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt index cba7d8e57..71b7c4adc 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt @@ -89,196 +89,184 @@ internal class ChannelDataCoordinatorTest { } @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 + 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() + coordinator.loadGlobalData() - assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) - } + 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 + 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) + val failure = DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout")) + dataLoadingFailures.value = setOf(failure) - coordinator.loadGlobalData() + coordinator.loadGlobalData() - val state = coordinator.globalLoadingState.value - assertIs(state) - assertEquals(1, state.failures.size) - assertEquals(DataLoadingStep.GlobalBTTVEmotes, state.failures.first().step) - } + 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() } - } + 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 + fun `loadChannelData transitions to Loaded`() = runTest(testDispatcher) { + val channel = UserName("testchannel") + coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded - coordinator.loadChannelData(channel) + coordinator.loadChannelData(channel) - assertEquals(ChannelLoadingState.Loaded, coordinator.getChannelLoadingState(channel).value) - } + 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) + 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) + coordinator.loadChannelData(channel) - val state = coordinator.getChannelLoadingState(channel).value - assertIs(state) - assertEquals(1, state.failures.size) - } + 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 + 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.loadGlobalData() + assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) - coordinator.globalLoadingState.test { - assertEquals(GlobalLoadingState.Loaded, awaitItem()) + coordinator.globalLoadingState.test { + assertEquals(GlobalLoadingState.Loaded, awaitItem()) - val chatFailure = ChatLoadingFailure(ChatLoadingStep.RecentMessages(UserName("test")), RuntimeException("fail")) - chatLoadingFailures.value = setOf(chatFailure) + val chatFailure = ChatLoadingFailure(ChatLoadingStep.RecentMessages(UserName("test")), RuntimeException("fail")) + chatLoadingFailures.value = setOf(chatFailure) - val failed = awaitItem() - assertIs(failed) - assertEquals(1, failed.chatFailures.size) - } + 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"))), - ) + 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) + coordinator.retryDataLoading(failedState) - coVerify { globalDataLoader.loadGlobalBTTVEmotes() } - } + 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"))), - ) + 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) + coordinator.retryDataLoading(failedState) - coVerify { channelDataLoader.loadChannelData(channel) } - } + 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"))), - ) + 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) + coordinator.retryDataLoading(failedState) - coVerify { channelDataLoader.loadChannelData(channel) } - } + 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"))), - ) + 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) + coordinator.retryDataLoading(failedState) - assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) - } + 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) + 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)) + val failedState = GlobalLoadingState.Failed(failures = setOf(failure)) - coordinator.retryDataLoading(failedState) + coordinator.retryDataLoading(failedState) - assertIs(coordinator.globalLoadingState.value) - } + assertIs(coordinator.globalLoadingState.value) + } @Test - fun `cleanupChannel removes channel state`() = - runTest(testDispatcher) { - val channel = UserName("testchannel") - coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded + 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.loadChannelData(channel) + assertEquals(ChannelLoadingState.Loaded, coordinator.getChannelLoadingState(channel).value) - coordinator.cleanupChannel(channel) + coordinator.cleanupChannel(channel) - assertEquals(ChannelLoadingState.Idle, coordinator.getChannelLoadingState(channel).value) - } + 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 index 343dbfc1e..6747d1508 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt @@ -81,138 +81,128 @@ internal class ChannelDataLoaderTest { } @Test - fun `loadChannelData returns Loaded when all steps succeed`() = - runTest(testDispatcher) { - coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo - stubAllEmotesAndBadgesSuccess() + fun `loadChannelData returns Loaded when all steps succeed`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo + stubAllEmotesAndBadgesSuccess() - val result = loader.loadChannelData(testChannel) + val result = loader.loadChannelData(testChannel) - assertEquals(ChannelLoadingState.Loaded, result) - } + 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() + 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) + val result = loader.loadChannelData(testChannel) - assertIs(result) - assertTrue(result.failures.isEmpty()) - } + 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() + 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) + val result = loader.loadChannelData(testChannel) - assertEquals(ChannelLoadingState.Loaded, result) - coVerify { getChannelsUseCase(listOf(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()) - } + 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 }) - } + 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 }) } - } + 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") + fun `loadChannelData returns Failed on unexpected exception`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } throws RuntimeException("unexpected") - val result = loader.loadChannelData(testChannel) + val result = loader.loadChannelData(testChannel) - assertIs(result) - assertTrue(result.failures.isEmpty()) - } + 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) - } + 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) + fun `loadChannelBadges returns null on success`() = runTest(testDispatcher) { + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.success(Unit) - val result = loader.loadChannelBadges(testChannel, testChannelId) + val result = loader.loadChannelBadges(testChannel, testChannelId) - assertEquals(null, result) - } + assertEquals(null, result) + } @Test - fun `loadChannelBadges returns failure on error`() = - runTest(testDispatcher) { - coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.failure(RuntimeException("fail")) + fun `loadChannelBadges returns failure on error`() = runTest(testDispatcher) { + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.failure(RuntimeException("fail")) - val result = loader.loadChannelBadges(testChannel, testChannelId) + val result = loader.loadChannelBadges(testChannel, testChannelId) - assertIs(result) - assertEquals(testChannel, result.channel) - } + assertIs(result) + assertEquals(testChannel, result.channel) + } } 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 index c40085207..d12e7c612 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt @@ -58,404 +58,379 @@ internal class FeatureTourViewModelTest { // -- Post-onboarding step resolution -- @Test - fun `initial state is Idle when onboarding not completed`() = - runTest(testDispatcher) { - viewModel.uiState.test { - assertEquals(PostOnboardingStep.Idle, awaitItem().postOnboardingStep) - } + 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) + 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) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = true, ready = true) - // State stays Idle — StateFlow deduplicates, no new emission - expectNoEvents() - } + // 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) + 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) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = false) - // State stays Idle — StateFlow deduplicates, no new emission - expectNoEvents() - } + // 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) - } + 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) - } + 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) + 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) + 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) - } + 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) - } + 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) + 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.OverflowMenu, expectMostRecentItem().currentTourStep) - viewModel.advance() - assertEquals(TourStep.ConfigureActions, expectMostRecentItem().currentTourStep) + viewModel.advance() + assertEquals(TourStep.ConfigureActions, expectMostRecentItem().currentTourStep) - viewModel.advance() - assertEquals(TourStep.SwipeGesture, expectMostRecentItem().currentTourStep) + viewModel.advance() + assertEquals(TourStep.SwipeGesture, expectMostRecentItem().currentTourStep) - viewModel.advance() - assertEquals(TourStep.RecoveryFab, 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) - } + 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) - } + 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() } + fun `startTour after completion is no-op`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + repeat(TourStep.entries.size) { viewModel.advance() } - viewModel.startTour() + viewModel.startTour() - assertFalse(expectMostRecentItem().isTourActive) - } + 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) + fun `completeTour persists tour version and clears step`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + repeat(TourStep.entries.size) { viewModel.advance() } + cancelAndIgnoreRemainingEvents() } - @Test - fun `advance persists current step index`() = - runTest(testDispatcher) { - viewModel.uiState.test { - skipItems(1) - setupAndStartTour() - viewModel.advance() // step 1 - cancelAndIgnoreRemainingEvents() - } + val persisted = settingsFlow.value + assertEquals(CURRENT_TOUR_VERSION, persisted.featureTourVersion) + assertEquals(0, persisted.featureTourStep) + assertTrue(persisted.hasShownToolbarHint) + } - assertEquals(1, settingsFlow.value.featureTourStep) + @Test + fun `advance persists current step index`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // step 1 + cancelAndIgnoreRemainingEvents() } - @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 + assertEquals(1, settingsFlow.value.featureTourStep) + } - viewModel.skipTour() + @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 - assertEquals(PostOnboardingStep.Complete, expectMostRecentItem().postOnboardingStep) - } + viewModel.skipTour() - val persisted = settingsFlow.value - assertEquals(CURRENT_TOUR_VERSION, persisted.featureTourVersion) - assertTrue(persisted.hasShownToolbarHint) + 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) + 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 + viewModel.onToolbarHintDismissed() + viewModel.onToolbarHintDismissed() // second call should be no-op - assertEquals(PostOnboardingStep.FeatureTour, expectMostRecentItem().postOnboardingStep) - } + 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) + 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) - } + 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) - } + 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) - } + 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) - } + 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) + 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) + 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) + 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 -- From 9e6690b36e8ad3ec50f95365b4ec6af8cff4619a Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 13:13:00 +0200 Subject: [PATCH 201/349] refactor(compose): Extract large composables into smaller private components --- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 161 ++-- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 828 +++++++++++------- .../ui/main/dialog/MainScreenDialogs.kt | 326 ++++--- .../dankchat/ui/main/input/ChatBottomBar.kt | 57 +- .../dankchat/ui/main/input/ChatInputLayout.kt | 139 +-- .../ui/main/sheet/FullScreenSheetOverlay.kt | 223 +++-- 6 files changed, 942 insertions(+), 792 deletions(-) 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 index dfbcf0062..61a3425d3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -361,66 +361,6 @@ private fun RecoveryFabs( // TooltipBox must be outside AnimatedVisibility — the scaleIn animation // transforms anchor bounds, causing M3 to miscalculate the caret position. - val tooltipContent: @Composable () -> Unit = { - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // More FAB ↔ Actions menu — hidden during tour so tooltip points at escape FAB - if (!showInput && fabMenuCallbacks != null && recoveryFabTooltipState == null) { - 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, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - ) - } - } - } - } - } - - AnimatedVisibility( - visible = visible, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - ) { - escapeFab() - } - } - } - if (recoveryFabTooltipState != null) { TooltipBox( positionProvider = @@ -441,7 +381,14 @@ private fun RecoveryFabs( hasAction = true, modifier = modifier, ) { - tooltipContent() + // During tour, only show escape FAB (menu toggle hidden so tooltip points at it) + AnimatedVisibility( + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + escapeFab() + } } } else { AnimatedVisibility( @@ -455,50 +402,62 @@ private fun RecoveryFabs( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (!showInput && fabMenuCallbacks != null) { - 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, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - ) - } - } + 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), + ) + } - escapeFab() + else -> { + SmallFloatingActionButton( + onClick = { onMenuExpandedChange(true) }, + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f), + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } } } } 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 index 6d9dd0e67..48ed6ac53 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -3,6 +3,7 @@ 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 @@ -68,8 +69,10 @@ import com.flxrs.dankchat.ui.chat.emote.LocalEmoteAnimationCoordinator import com.flxrs.dankchat.ui.chat.emote.rememberEmoteAnimationCoordinator 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 @@ -85,10 +88,12 @@ 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 @@ -232,41 +237,14 @@ fun MainScreen( val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel - // Post-onboarding flow: toolbar hint → feature tour - val channelsReady = !tabState.loading - val channelsEmpty = tabState.tabs.isEmpty() && channelsReady - - // 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. - // Tooltip .show() calls live in FloatingToolbar. - 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 gestureInputHidden with MainScreenViewModel (only during active tour - // to avoid resetting the persisted state on Activity recreation) - LaunchedEffect(featureTourState.gestureInputHidden, featureTourState.isTourActive) { - if (featureTourState.isTourActive) { - mainScreenViewModel.setGestureInputHidden(featureTourState.gestureInputHidden) - } - } + MainScreenTourEffects( + featureTourViewModel = featureTourViewModel, + featureTourState = featureTourState, + mainScreenViewModel = mainScreenViewModel, + mainState = mainState, + channelsReady = !tabState.loading, + channelsEmpty = tabState.tabs.isEmpty() && !tabState.loading, + ) MainScreenDialogs( dialogViewModel = dialogViewModel, @@ -303,20 +281,6 @@ fun MainScreen( val effectiveShowInput = mainState.effectiveShowInput val effectiveShowAppBar = mainState.effectiveShowAppBar - // Auto-advance tour when input is hidden during the SwipeGesture step (e.g. by actual swipe) - LaunchedEffect(mainState.gestureInputHidden, featureTourState.currentTourStep) { - if (mainState.gestureInputHidden && featureTourState.currentTourStep == TourStep.SwipeGesture) { - featureTourViewModel.advance() - } - } - - // Keep toolbar visible during tour - LaunchedEffect(featureTourState.isTourActive, mainState.gestureToolbarHidden) { - if (featureTourState.isTourActive && mainState.gestureToolbarHidden) { - mainScreenViewModel.setGestureToolbarHidden(false) - } - } - val toolbarTracker = remember { ScrollDirectionTracker( @@ -346,59 +310,20 @@ fun MainScreen( val inputHeightDp = with(density) { inputHeightPx.toDp() } val helperTextHeightDp = with(density) { helperTextHeightPx.toDp() } - // Clear focus when keyboard fully reaches the bottom, but not when - // switching to the emote menu. Prevents keyboard from reopening when - // returning from background. Debounced to avoid premature focus loss - // during heavy recomposition (e.g. emote loading/reparsing). val focusManager = LocalFocusManager.current - LaunchedEffect(Unit) { - snapshotFlow { imeHeightState.value == 0 && !inputState.isEmoteMenuOpen } - .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() - } - } - - // 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) { - channelPagerViewModel.setActivePage(composePagerState.currentPage) - } - } - - // Clear unread/mention indicators when page settles - LaunchedEffect(composePagerState.settledPage) { - channelPagerViewModel.clearNotifications(composePagerState.settledPage) - } + MainScreenFocusEffects( + imeHeight = imeHeightState, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + currentStream = currentStream, + ) - // Pager swipe reveals toolbar - LaunchedEffect(composePagerState.isScrollInProgress) { - if (composePagerState.isScrollInProgress) { - mainScreenViewModel.setGestureToolbarHidden(false) - } - } + MainScreenPagerEffects( + composePagerState = composePagerState, + pagerState = pagerState, + onSetActivePage = channelPagerViewModel::setActivePage, + onClearNotifications = channelPagerViewModel::clearNotifications, + onShowToolbar = { mainScreenViewModel.setGestureToolbarHidden(false) }, + ) val emoteCoordinator = rememberEmoteAnimationCoordinator() val customTabContext = LocalContext.current @@ -794,260 +719,196 @@ fun MainScreen( } if (useWideSplitLayout) { - // --- Wide split layout: stream (left) | handle | chat (right) --- - 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 - Box( - modifier = - Modifier - .weight(splitFraction) - .fillMaxSize(), - ) { - StreamView( - channel = currentStream, - streamViewModel = streamViewModel, - fillPane = true, - onClose = { - keyboardController?.hide() - focusManager.clearFocus() - streamViewModel.closeStream() - }, - modifier = Modifier.fillMaxSize(), - ) - } - - // Right pane: Chat + all overlays - Box( - modifier = - Modifier - .weight(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 && !inputState.isEmoteMenuOpen && !isSheetOpen, - false, - showTabsInSplit, - ) - - // Status bar scrim when toolbar is gesture-hidden - if (!isFullscreen && mainState.gestureToolbarHidden) { - StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) - } - - fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) - - // Dismiss scrim for input overflow menu - if (inputOverflowExpanded) { - InputDismissScrim( - forceOpen = featureTourState.forceOverflowOpen, - onDismiss = { inputOverflowExpanded = false }, - ) - } + WideSplitLayout( + currentStream = currentStream, + streamViewModel = streamViewModel, + 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, + effectiveShowInput = effectiveShowInput, + inputOverflowExpanded = inputOverflowExpanded, + forceOverflowOpen = featureTourState.forceOverflowOpen, + swipeDownThresholdPx = swipeDownThresholdPx, + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + onHideInput = { mainScreenViewModel.setGestureInputHidden(true) }, + onDismissOverflow = { inputOverflowExpanded = false }, + modifier = modifier, + ) + } else { + NormalStackedLayout( + currentStream = currentStream, + streamViewModel = streamViewModel, + 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, + isWideWindow = isWideWindow, + isLandscape = isLandscape, + effectiveShowInput = effectiveShowInput, + inputOverflowExpanded = inputOverflowExpanded, + forceOverflowOpen = featureTourState.forceOverflowOpen, + swipeDownThresholdPx = swipeDownThresholdPx, + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + onHideInput = { mainScreenViewModel.setGestureInputHidden(true) }, + onDismissOverflow = { inputOverflowExpanded = false }, + modifier = modifier, + ) + } + } + } +} - // Input bar - rendered after sheet overlay so it's on top - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ), - ) { - bottomBar() - } +@Composable +private fun BoxScope.WideSplitLayout( + currentStream: UserName?, + streamViewModel: StreamViewModel, + 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, + effectiveShowInput: Boolean, + inputOverflowExpanded: Boolean, + forceOverflowOpen: Boolean, + swipeDownThresholdPx: Float, + suggestions: ImmutableList, + onSuggestionClick: (Suggestion) -> Unit, + onHideInput: () -> Unit, + onDismissOverflow: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.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 + Box( + modifier = + Modifier + .weight(splitFraction) + .fillMaxSize(), + ) { + StreamView( + channel = currentStream ?: return, + streamViewModel = streamViewModel, + fillPane = true, + onClose = { + keyboardController?.hide() + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = Modifier.fillMaxSize(), + ) + } - emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - - if (effectiveShowInput && isKeyboardVisible) { - SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, - modifier = - Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .imePadding() - .padding(bottom = inputHeightDp + 2.dp), - ) - } - } - } + // Right pane: Chat + all overlays + Box( + modifier = + Modifier + .weight(1f - splitFraction) + .fillMaxSize(), + ) { + val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - // Draggable handle overlaid at the split edge - 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() }, - ) - } - } else { - // --- Normal stacked layout (portrait / narrow-without-stream / PiP) --- - 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) - } - } // end !isInPipMode - - // Stream View layer - currentStream?.let { channel -> - val showStream = isInPipMode || !isKeyboardVisible || isLandscape - // Delay adding StreamView to composition to prevent WebView flash on first open. - // If the WebView was already attached (e.g. switching from wide layout), skip the delay. - var streamComposed by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } - LaunchedEffect(showStream) { - if (showStream) { - delay(100) - streamComposed = true - } else { - streamComposed = false - } - } - if (showStream && streamComposed) { - StreamView( - channel = channel, - streamViewModel = streamViewModel, - isInPipMode = isInPipMode, - onClose = { - keyboardController?.hide() - focusManager.clearFocus() - streamViewModel.closeStream() - }, - modifier = - if (isInPipMode) { - Modifier.fillMaxSize() - } else { - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .graphicsLayer { alpha = streamState.alpha.value } - .onSizeChanged { size -> - streamState.heightDp = with(density) { size.height.toDp() } - } - }, + Scaffold( + modifier = + modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), + contentWindowInsets = WindowInsets(0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = inputHeightDp), ) - } - if (!showStream) { - streamState.heightDp = 0.dp - } + }, + ) { paddingValues -> + scaffoldContent(paddingValues, statusBarTop) } - // Status bar scrim when stream is active — fades with stream/toolbar - if (currentStream != null && !isFullscreen && !isInPipMode) { - StatusBarScrim( - colorAlpha = 1f, - modifier = - Modifier - .align(Alignment.TopCenter) - .graphicsLayer { alpha = streamState.alpha.value }, - ) - } + val chatPaneWidthDp = with(density) { (containerWidthPx * (1f - splitFraction)).toInt().toDp() } + val showTabsInSplit = chatPaneWidthDp > 250.dp - // Floating Toolbars - collapsible tabs (expand on swipe) + actions - if (!isInPipMode) { - floatingToolbar( - Modifier.align(Alignment.TopCenter), - (!isWideWindow || (!isKeyboardVisible && !inputState.isEmoteMenuOpen)) && !isSheetOpen, - true, - true, - ) - } + floatingToolbar( + Modifier.align(Alignment.TopCenter), + !isKeyboardVisible && !isEmoteMenuOpen && !isSheetOpen, + false, + showTabsInSplit, + ) - // Status bar scrim when toolbar is gesture-hidden — keeps status bar readable - if (!isInPipMode && !isFullscreen && mainState.gestureToolbarHidden) { + if (!isFullscreen && gestureToolbarHidden) { StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) } - // Fullscreen Overlay Sheets — after toolbar/scrims so sheets render on top - if (!isInPipMode) { - fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) - } + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) - // Dismiss scrim for input overflow menu — before input bar so menu items stay clickable - if (!isInPipMode && inputOverflowExpanded) { + if (inputOverflowExpanded) { InputDismissScrim( - forceOpen = featureTourState.forceOverflowOpen, - onDismiss = { inputOverflowExpanded = false }, + forceOpen = forceOverflowOpen, + onDismiss = onDismissOverflow, ) } - // Input bar — on top of sheets and dismiss scrim for whisper/reply input - if (!isInPipMode) { - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = scaffoldBottomPadding) - .swipeDownToHide( - enabled = effectiveShowInput, - thresholdPx = swipeDownThresholdPx, - onHide = { mainScreenViewModel.setGestureInputHidden(true) }, - ), - ) { - bottomBar() - } + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = onHideInput, + ), + ) { + bottomBar() } - // Emote Menu Layer - slides up/down independently of keyboard - // Fast tween to match system keyboard animation speed - if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - if (!isInPipMode && effectiveShowInput && isKeyboardVisible) { + if (effectiveShowInput && isKeyboardVisible) { SuggestionDropdown( - suggestions = inputState.suggestions, - onSuggestionClick = chatInputViewModel::applySuggestion, + suggestions = suggestions, + onSuggestionClick = onSuggestionClick, modifier = Modifier .align(Alignment.BottomStart) @@ -1058,5 +919,304 @@ fun MainScreen( } } } + + 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?, + streamViewModel: StreamViewModel, + 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, + isWideWindow: Boolean, + isLandscape: Boolean, + effectiveShowInput: Boolean, + inputOverflowExpanded: Boolean, + forceOverflowOpen: Boolean, + swipeDownThresholdPx: Float, + suggestions: ImmutableList, + onSuggestionClick: (Suggestion) -> Unit, + onHideInput: () -> Unit, + onDismissOverflow: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + 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 + currentStream?.let { channel -> + val showStream = isInPipMode || !isKeyboardVisible || isLandscape + var streamComposed by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } + LaunchedEffect(showStream) { + if (showStream) { + delay(100) + streamComposed = true + } else { + streamComposed = false + } + } + if (showStream && streamComposed) { + StreamView( + channel = channel, + streamViewModel = streamViewModel, + isInPipMode = isInPipMode, + onClose = { + keyboardController?.hide() + focusManager.clearFocus() + streamViewModel.closeStream() + }, + modifier = + if (isInPipMode) { + Modifier.fillMaxSize() + } else { + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .graphicsLayer { alpha = streamState.alpha.value } + .onSizeChanged { size -> + streamState.heightDp = with(density) { size.height.toDp() } + } + }, + ) + } + if (!showStream) { + streamState.heightDp = 0.dp + } + } + + // Status bar scrim when stream is active + if (currentStream != null && !isFullscreen && !isInPipMode) { + StatusBarScrim( + colorAlpha = 1f, + modifier = + Modifier + .align(Alignment.TopCenter) + .graphicsLayer { alpha = streamState.alpha.value }, + ) + } + + if (!isInPipMode) { + floatingToolbar( + Modifier.align(Alignment.TopCenter), + (!isWideWindow || (!isKeyboardVisible && !isEmoteMenuOpen)) && !isSheetOpen, + true, + true, + ) + } + + if (!isInPipMode && !isFullscreen && gestureToolbarHidden) { + StatusBarScrim(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 = effectiveShowInput, + thresholdPx = swipeDownThresholdPx, + onHide = onHideInput, + ), + ) { + bottomBar() + } + } + + if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + + if (!isInPipMode && effectiveShowInput && 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 gestureInputHidden with MainScreenViewModel + LaunchedEffect(featureTourState.gestureInputHidden, featureTourState.isTourActive) { + if (featureTourState.isTourActive) { + mainScreenViewModel.setGestureInputHidden(featureTourState.gestureInputHidden) + } + } + + // Auto-advance tour when input is hidden during the SwipeGesture step + LaunchedEffect(mainState.gestureInputHidden, featureTourState.currentTourStep) { + if (mainState.gestureInputHidden && 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 + + // Clear focus when keyboard fully reaches the bottom, but not when + // switching to the emote menu. Debounced to avoid premature focus loss. + LaunchedEffect(Unit) { + snapshotFlow { imeHeight.value == 0 && !isEmoteMenuOpen } + .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/dialog/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt index 99380106f..083a55f0a 100644 --- 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 @@ -35,11 +35,14 @@ 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.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.ui.chat.BadgeUi import com.flxrs.dankchat.ui.chat.emote.EmoteInfoViewModel +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.channel.ChannelManagementViewModel import com.flxrs.dankchat.ui.main.input.ChatInputViewModel @@ -105,20 +108,10 @@ fun MainScreenDialogs( } if (dialogState.showModActions && modActionsChannel != null) { - val modActionsViewModel: ModActionsViewModel = - koinViewModel( - key = "mod-actions-${modActionsChannel.value}", - parameters = { parametersOf(modActionsChannel) }, - ) - val shieldModeActive by modActionsViewModel.shieldModeActive.collectAsStateWithLifecycle() - ModActionsDialog( - roomState = modActionsViewModel.roomState, - isBroadcaster = modActionsViewModel.isBroadcaster, + ModActionsDialogContainer( + channel = modActionsChannel, isStreamActive = isStreamActive, - shieldModeActive = shieldModeActive, - onSendCommand = { command -> - chatInputViewModel.trySendMessageOrCommand(command) - }, + onSendCommand = chatInputViewModel::trySendMessageOrCommand, onAnnounce = { chatInputViewModel.setAnnouncing(true) }, onDismiss = dialogViewModel::dismissModActions, ) @@ -214,130 +207,41 @@ fun MainScreenDialogs( } dialogState.messageOptionsParams?.let { params -> - val viewModel: MessageOptionsViewModel = - koinViewModel( - key = params.messageId, - parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) }, - ) - val state by viewModel.state.collectAsStateWithLifecycle() - (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 = { - chatInputViewModel.setReplying(true, s.messageId, s.replyName) - }, - onReplyToOriginal = { - chatInputViewModel.setReplying(true, s.rootThreadId, s.rootThreadName ?: s.replyName) - }, - onViewThread = { - sheetNavigationViewModel.openReplies(s.rootThreadId, s.replyName) - }, - onCopy = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", s.originalMessage))) - snackbarHostState.showSnackbar(messageCopiedMsg) - } - }, - onCopyFullMessage = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", params.fullMessage))) - snackbarHostState.showSnackbar(messageCopiedMsg) - } - }, - onCopyMessageId = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", s.messageId))) - snackbarHostState.showSnackbar(messageIdCopiedMsg) - } - }, - onDelete = viewModel::deleteMessage, - onTimeout = viewModel::timeoutUser, - onBan = viewModel::banUser, - onUnban = viewModel::unbanUser, - onDismiss = dialogViewModel::dismissMessageOptions, - ) - } + MessageOptionsDialogContainer( + params = params, + snackbarHostState = snackbarHostState, + onJumpToMessage = onJumpToMessage, + onSetReplying = chatInputViewModel::setReplying, + onOpenReplies = sheetNavigationViewModel::openReplies, + onDismiss = dialogViewModel::dismissMessageOptions, + ) } dialogState.emoteInfoEmotes?.let { emotes -> - val viewModel: EmoteInfoViewModel = - koinViewModel( - key = emotes.joinToString { it.id }, - parameters = { parametersOf(emotes) }, - ) - val sheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() - val whisperTarget by chatInputViewModel.whisperTarget.collectAsStateWithLifecycle() - 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 = { chatInputViewModel.insertText("$it ") }, - onCopyEmote = { - scope.launch { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("emote", it))) - } - }, - onOpenLink = { onOpenUrl(it) }, + EmoteInfoDialogContainer( + emotes = emotes, + isLoggedIn = isLoggedIn, + onInsertText = chatInputViewModel::insertText, + onOpenUrl = onOpenUrl, onDismiss = dialogViewModel::dismissEmoteInfo, ) } dialogState.userPopupParams?.let { params -> - 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 = dialogViewModel::dismissUserPopup, - onMention = { name, displayName -> - chatInputViewModel.mentionUser( - user = UserName(name), - display = DisplayName(displayName), - ) - }, - onWhisper = { name -> + UserPopupDialogContainer( + params = params, + onMention = chatInputViewModel::mentionUser, + onWhisper = { userName -> sheetNavigationViewModel.openWhispers() - chatInputViewModel.setWhisperTarget(UserName(name)) - }, - onOpenChannel = { userName -> onOpenUrl("https://twitch.tv/$userName") }, - onReport = { _ -> - onReportChannel() + chatInputViewModel.setWhisperTarget(userName) }, - onMessageHistory = { userName -> - params.channel?.let { channel -> - sheetNavigationViewModel.openHistory(channel, "from:$userName") - } + onOpenUrl = onOpenUrl, + onReportChannel = onReportChannel, + onOpenHistory = { channel, filter -> + sheetNavigationViewModel.openHistory(channel, filter) dialogViewModel.dismissUserPopup() }, + onDismiss = dialogViewModel::dismissUserPopup, ) } @@ -427,3 +331,175 @@ private fun UploadDisclaimerSheet( } } } + +@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() + ModActionsDialog( + roomState = viewModel.roomState, + isBroadcaster = viewModel.isBroadcaster, + isStreamActive = isStreamActive, + shieldModeActive = shieldModeActive, + onSendCommand = onSendCommand, + onAnnounce = onAnnounce, + onDismiss = onDismiss, + ) +} + +@Composable +private fun MessageOptionsDialogContainer( + params: MessageOptionsParams, + snackbarHostState: SnackbarHostState, + onJumpToMessage: (String, UserName) -> Unit, + onSetReplying: (Boolean, String, UserName) -> Unit, + onOpenReplies: (String, UserName) -> Unit, + onDismiss: () -> Unit, +) { + val viewModel: MessageOptionsViewModel = + koinViewModel( + key = params.messageId, + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) }, + ) + val state by viewModel.state.collectAsStateWithLifecycle() + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + val messageCopiedMsg = stringResource(R.string.snackbar_message_copied) + val messageIdCopiedMsg = stringResource(R.string.snackbar_message_id_copied) + + (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) }, + onReplyToOriginal = { onSetReplying(true, s.rootThreadId, s.rootThreadName ?: s.replyName) }, + onViewThread = { onOpenReplies(s.rootThreadId, s.replyName) }, + onCopy = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", s.originalMessage))) + snackbarHostState.showSnackbar(messageCopiedMsg) + } + }, + onCopyFullMessage = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", params.fullMessage))) + snackbarHostState.showSnackbar(messageCopiedMsg) + } + }, + onCopyMessageId = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", s.messageId))) + snackbarHostState.showSnackbar(messageIdCopiedMsg) + } + }, + 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: (UserName, String) -> Unit, + 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 = { name, displayName -> + onMention(UserName(name), DisplayName(displayName)) + }, + onWhisper = { name -> onWhisper(UserName(name)) }, + onOpenChannel = { userName -> onOpenUrl("https://twitch.tv/$userName") }, + onReport = { _ -> onReportChannel() }, + onMessageHistory = { userName -> + params.channel?.let { channel -> + onOpenHistory(channel, "from:$userName") + } + }, + ) +} 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 index 74baed70c..cf7d00e50 100644 --- 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 @@ -99,11 +99,7 @@ fun ChatBottomBar( // Sticky helper text + nav bar spacer when input is hidden if (!showInput && !isSheetOpen) { val helperTextState = uiState.helperText - val resolvedRoomState = helperTextState.roomStateParts.map { it.resolve() } - val roomStateText = resolvedRoomState.joinToString(separator = ", ") - val streamInfoText = helperTextState.streamInfo - val combinedText = listOfNotNull(roomStateText.ifEmpty { null }, streamInfoText).joinToString(separator = " - ") - if (combinedText.isNotEmpty()) { + if (!helperTextState.isEmpty) { val horizontalPadding = when { isFullscreen && isInSplitLayout -> { @@ -120,9 +116,6 @@ fun ChatBottomBar( PaddingValues(horizontal = 16.dp) } } - val textMeasurer = rememberTextMeasurer() - val style = MaterialTheme.typography.labelSmall - val density = LocalDensity.current Surface( color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.85f), modifier = @@ -130,54 +123,14 @@ fun ChatBottomBar( .fillMaxWidth() .onSizeChanged { onHelperTextHeightChange(it.height) }, ) { - var expanded by remember { mutableStateOf(false) } - BoxWithConstraints( + ExpandableHelperText( + helperText = helperTextState, modifier = Modifier .navigationBarsPadding() - .fillMaxWidth() - .clickable { expanded = !expanded } .padding(horizontalPadding) - .padding(vertical = 6.dp) - .animateContentSize(), - ) { - val maxWidthPx = with(density) { maxWidth.roundToPx() } - val fitsOnOneLine = - remember(combinedText, style, maxWidthPx) { - textMeasurer.measure(combinedText, style).size.width <= maxWidthPx - } - val showTwoLines = expanded && !fitsOnOneLine && streamInfoText != null && roomStateText.isNotEmpty() - 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), - ) - } - } - } + .padding(vertical = 6.dp), + ) } } } 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 index ba920b337..458cf8005 100644 --- 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 @@ -295,67 +295,7 @@ fun ChatInputLayout( onKeyboardAction = { if (canSend) onSend() }, ) - // Helper text (roomstate + live info) - val resolvedRoomState = helperText.roomStateParts.map { it.resolve() } - val roomStateText = resolvedRoomState.joinToString(separator = ", ") - val streamInfoText = helperText.streamInfo - AnimatedVisibility( - visible = !helperText.isEmpty, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - 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() - .clickable { expanded = !expanded } - .padding(horizontal = 16.dp) - .padding(bottom = 4.dp) - .animateContentSize(), - ) { - val maxWidthPx = with(density) { maxWidth.roundToPx() } - val fitsOnOneLine = - remember(combinedText, style, maxWidthPx) { - textMeasurer.measure(combinedText, style).size.width <= maxWidthPx - } - val showTwoLines = expanded && !fitsOnOneLine && streamInfoText != null && roomStateText.isNotEmpty() - 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), - ) - } - } - } - } + HelperTextRow(helperText = helperText) // Progress indicator for uploads and data loading AnimatedVisibility( @@ -868,3 +808,80 @@ private fun EndAlignedActionGroup( 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() + .clickable { expanded = !expanded } + .animateContentSize(), + ) { + val maxWidthPx = with(density) { maxWidth.roundToPx() } + val fitsOnOneLine = + remember(combinedText, style, maxWidthPx) { + textMeasurer.measure(combinedText, style).size.width <= maxWidthPx + } + val showTwoLines = expanded && !fitsOnOneLine && streamInfoText != null && roomStateText.isNotEmpty() + 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/sheet/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt index 4187968f6..7aa9ed815 100644 --- 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 @@ -49,42 +49,7 @@ fun FullScreenSheetOverlay( exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), modifier = modifier.fillMaxSize(), ) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - val popupOnlyClickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, _ -> - onUserClick( - UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge }, - ), - ) - } - - val mentionableClickHandler: (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( - UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge }, - ), - ) - } else { - onUserMention(UserName(userName), DisplayName(displayName)) - } - } - + Box(modifier = Modifier.fillMaxSize()) { when (sheetState) { is FullScreenSheetState.Closed -> { Unit @@ -95,20 +60,8 @@ fun FullScreenSheetOverlay( mentionViewModel = mentionViewModel, initialisWhisperTab = false, onDismiss = onDismiss, - onUserClick = popupOnlyClickHandler, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageLongClick( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = false, - canReply = false, - canCopy = true, - canJump = true, - ), - ) - }, + onUserClick = popupOnlyClickHandler(onUserClick), + onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = true), onEmoteClick = onEmoteClick, onWhisperReply = onWhisperReply, bottomContentPadding = bottomContentPadding, @@ -120,20 +73,8 @@ fun FullScreenSheetOverlay( mentionViewModel = mentionViewModel, initialisWhisperTab = true, onDismiss = onDismiss, - onUserClick = popupOnlyClickHandler, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageLongClick( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = false, - canReply = false, - canCopy = true, - canJump = false, - ), - ) - }, + onUserClick = popupOnlyClickHandler(onUserClick), + onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = false), onEmoteClick = onEmoteClick, onWhisperReply = onWhisperReply, bottomContentPadding = bottomContentPadding, @@ -144,73 +85,117 @@ fun FullScreenSheetOverlay( RepliesSheet( rootMessageId = sheetState.replyMessageId, onDismiss = onDismissReplies, - onUserClick = mentionableClickHandler, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageLongClick( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = false, - canReply = false, - canCopy = true, - canJump = true, - ), - ) - }, + onUserClick = mentionableClickHandler(onUserClick, onUserMention, userLongClickBehavior), + onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = true), bottomContentPadding = bottomContentPadding, ) } is FullScreenSheetState.History -> { - val viewModel: MessageHistoryViewModel = - koinViewModel( - key = "history-${sheetState.channel.value}", - parameters = { parametersOf(sheetState.channel) }, - ) - val historyClickHandler: (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( - UserPopupStateParams( - targetUserId = userId?.let { UserId(it) } ?: UserId(""), - targetUserName = UserName(userName), - targetDisplayName = DisplayName(displayName), - channel = channel?.let { UserName(it) }, - badges = badges.map { it.badge }, - ), - ) - } else { - viewModel.insertText("${UserName(userName).valueOrDisplayName(DisplayName(displayName))} ") - } - } - MessageHistorySheet( - viewModel = viewModel, + HistorySheetContent( channel = sheetState.channel, initialFilter = sheetState.initialFilter, onDismiss = onDismiss, - onUserClick = historyClickHandler, - onMessageLongClick = { messageId, channel, fullMessage -> - onMessageLongClick( - MessageOptionsParams( - messageId = messageId, - channel = channel?.let { UserName(it) }, - fullMessage = fullMessage, - canModerate = false, - canReply = false, - canCopy = true, - canJump = true, - ), - ) - }, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, + userLongClickBehavior = userLongClickBehavior, + bottomContentPadding = bottomContentPadding, ) } } } } } + +@Composable +private fun HistorySheetContent( + channel: UserName, + initialFilter: String, + onDismiss: () -> Unit, + onUserClick: (UserPopupStateParams) -> Unit, + onMessageLongClick: (MessageOptionsParams) -> Unit, + onEmoteClick: (List) -> Unit, + userLongClickBehavior: UserLongClickBehavior, + bottomContentPadding: Dp, +) { + val viewModel: MessageHistoryViewModel = + koinViewModel( + key = "history-${channel.value}", + parameters = { parametersOf(channel) }, + ) + val clickHandler: (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, clickChannel, badges, isLongPress -> + val shouldOpenPopup = + when (userLongClickBehavior) { + UserLongClickBehavior.MentionsUser -> !isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress + } + if (shouldOpenPopup) { + onUserClick(buildUserPopupParams(userId, userName, displayName, clickChannel, badges)) + } else { + viewModel.insertText("${UserName(userName).valueOrDisplayName(DisplayName(displayName))} ") + } + } + MessageHistorySheet( + viewModel = viewModel, + channel = channel, + 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 }, +) From fa15194d144b1d2ff7bd135e4aa1b7b4c45de5ca Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 13:30:40 +0200 Subject: [PATCH 202/349] refactor(compose): Extract menu content, shared sheet toolbar, and emote grid page --- .../dankchat/ui/chat/emotemenu/EmoteMenu.kt | 161 ++++++++------ .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 200 +++++++++++------- .../dankchat/ui/main/sheet/MentionSheet.kt | 161 ++++++++------ .../dankchat/ui/main/sheet/RepliesSheet.kt | 71 ++----- 4 files changed, 315 insertions(+), 278 deletions(-) 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 index f9c6723f7..449582164 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -38,6 +39,7 @@ 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 @@ -112,76 +114,12 @@ fun EmoteMenu( beyondViewportPageCount = 1, ) { page -> val tab = tabItems[page] - 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), // Offset below logo - ) - } - } else { - val gridState = if (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) }, - ) - } - } - } - } - } + EmoteGridPage( + tab = tab, + subsGridState = subsGridState, + navBarBottomDp = navBarBottomDp, + onEmoteClick = onEmoteClick, + ) } // Floating backspace button at bottom-end, matching keyboard position @@ -207,3 +145,86 @@ fun EmoteMenu( } } } + +@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/ui/main/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt index f51e06bdb..b9b2918c9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -12,6 +12,7 @@ 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 @@ -157,88 +158,26 @@ fun InlineOverflowMenu( .padding(vertical = 8.dp), ) { when (menu) { - AppBarMenu.Main -> { - if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { - onAction(ToolbarAction.Login) - onDismiss() - } - } else { - InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { - onAction(ToolbarAction.Relogin) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { - onAction(ToolbarAction.Logout) - onDismiss() - } - } + AppBarMenu.Main -> MainMenuContent( + isLoggedIn = isLoggedIn, + onAction = onAction, + onDismiss = onDismiss, + onNavigateToUpload = { currentMenu = AppBarMenu.Upload }, + onNavigateToChannel = { currentMenu = AppBarMenu.Channel }, + ) - HorizontalDivider() + AppBarMenu.Upload -> UploadMenuContent( + onAction = onAction, + onDismiss = onDismiss, + onBack = { currentMenu = AppBarMenu.Main }, + ) - InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { - onAction(ToolbarAction.ManageChannels) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { - onAction(ToolbarAction.RemoveChannel) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { - onAction(ToolbarAction.ReloadEmotes) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { - onAction(ToolbarAction.Reconnect) - onDismiss() - } - - HorizontalDivider() - - InlineMenuItem(text = stringResource(R.string.upload_media), icon = Icons.Default.CloudUpload, hasSubMenu = true) { currentMenu = AppBarMenu.Upload } - InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true) { currentMenu = AppBarMenu.Channel } - - HorizontalDivider() - - InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { - onAction(ToolbarAction.OpenSettings) - onDismiss() - } - } - - AppBarMenu.Upload -> { - InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { - onAction(ToolbarAction.CaptureImage) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { - onAction(ToolbarAction.CaptureVideo) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { - onAction(ToolbarAction.ChooseMedia) - onDismiss() - } - } - - AppBarMenu.Channel -> { - InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = { currentMenu = AppBarMenu.Main }) - InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { - onAction(ToolbarAction.OpenChannel) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { - onAction(ToolbarAction.ReportChannel) - onDismiss() - } - if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { - onAction(ToolbarAction.BlockChannel) - onDismiss() - } - } - } + AppBarMenu.Channel -> ChannelMenuContent( + isLoggedIn = isLoggedIn, + onAction = onAction, + onDismiss = onDismiss, + onBack = { currentMenu = AppBarMenu.Main }, + ) } } } @@ -330,3 +269,104 @@ private fun InlineSubMenuHeader( ) } } + +@Composable +private fun ColumnScope.MainMenuContent( + isLoggedIn: Boolean, + onAction: (ToolbarAction) -> Unit, + onDismiss: () -> Unit, + onNavigateToUpload: () -> Unit, + onNavigateToChannel: () -> Unit, +) { + if (!isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { + onAction(ToolbarAction.Login) + onDismiss() + } + } else { + InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { + onAction(ToolbarAction.Relogin) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { + onAction(ToolbarAction.Logout) + onDismiss() + } + } + + HorizontalDivider() + + InlineMenuItem(text = stringResource(R.string.manage_channels), icon = Icons.Default.EditNote) { + onAction(ToolbarAction.ManageChannels) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { + onAction(ToolbarAction.RemoveChannel) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { + onAction(ToolbarAction.ReloadEmotes) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { + onAction(ToolbarAction.Reconnect) + onDismiss() + } + + HorizontalDivider() + + InlineMenuItem(text = stringResource(R.string.upload_media), icon = Icons.Default.CloudUpload, hasSubMenu = true, onClick = onNavigateToUpload) + InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true, onClick = onNavigateToChannel) + + HorizontalDivider() + + InlineMenuItem(text = stringResource(R.string.settings), icon = Icons.Default.Settings) { + onAction(ToolbarAction.OpenSettings) + onDismiss() + } +} + +@Composable +private fun ColumnScope.UploadMenuContent( + onAction: (ToolbarAction) -> Unit, + onDismiss: () -> Unit, + onBack: () -> Unit, +) { + InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = onBack) + InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { + onAction(ToolbarAction.CaptureImage) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { + onAction(ToolbarAction.CaptureVideo) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { + onAction(ToolbarAction.ChooseMedia) + onDismiss() + } +} + +@Composable +private fun ColumnScope.ChannelMenuContent( + isLoggedIn: Boolean, + onAction: (ToolbarAction) -> Unit, + onDismiss: () -> Unit, + onBack: () -> Unit, +) { + InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = onBack) + InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { + onAction(ToolbarAction.OpenChannel) + onDismiss() + } + InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { + onAction(ToolbarAction.ReportChannel) + onDismiss() + } + if (isLoggedIn) { + InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { + onAction(ToolbarAction.BlockChannel) + onDismiss() + } + } +} 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 index 8e423eba7..fc8604706 100644 --- 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 @@ -11,8 +11,10 @@ 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.WindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -40,6 +42,7 @@ 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 @@ -145,87 +148,105 @@ fun MentionSheet( ) } - AnimatedVisibility( + SheetToolbar( visible = toolbarVisible, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), - modifier = Modifier.align(Alignment.TopCenter), + statusBarHeight = statusBarHeight, + sheetBackgroundColor = sheetBackgroundColor, + onBack = onDismiss, ) { - 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), + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top, - ) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), + 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, ) } } + } + } + } + } +} - 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, - ) - } - } - } +@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 (!toolbarVisible) { - Box( - modifier = - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)), - ) - } + 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/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt index 68b11607f..37786d54d 100644 --- 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 @@ -122,68 +122,23 @@ fun RepliesSheet( modifier = Modifier.fillMaxSize(), ) - AnimatedVisibility( + SheetToolbar( visible = toolbarVisible, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), - modifier = Modifier.align(Alignment.TopCenter), + statusBarHeight = statusBarHeight, + sheetBackgroundColor = sheetBackgroundColor, + onBack = onDismiss, ) { - 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), + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - ) { - 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, - modifier = Modifier.padding(start = 8.dp), - ) { - Text( - text = stringResource(R.string.replies_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - } + Text( + text = stringResource(R.string.replies_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) } } - - if (!toolbarVisible) { - Box( - modifier = - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarHeight) - .background(sheetBackgroundColor.copy(alpha = 0.7f)), - ) - } } } From 6e28c11eceb11e4ff5a2ba244867d39adad3948b Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 14:23:03 +0200 Subject: [PATCH 203/349] fix(stream): Hide stream for emote menu, fix toolbar alpha getting stuck --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 5 +-- .../dankchat/ui/main/StreamToolbarState.kt | 44 ++++++++----------- 2 files changed, 21 insertions(+), 28 deletions(-) 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 index 48ed6ac53..b90f1b0f2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -188,8 +188,7 @@ fun MainScreen( val streamVmState by streamViewModel.streamState.collectAsStateWithLifecycle() val currentStream = streamVmState.currentStream val hasStreamData = streamVmState.hasStreamData - val imeTargetBottom = WindowInsets.imeAnimationTarget.getBottom(density) - val streamState = rememberStreamToolbarState(currentStream, isKeyboardVisible, imeTargetBottom) + val streamState = rememberStreamToolbarState(currentStream) // PiP state — observe via lifecycle since onPause fires when entering PiP val isInPipMode = observePipMode(streamViewModel) @@ -990,7 +989,7 @@ private fun BoxScope.NormalStackedLayout( // Stream View layer currentStream?.let { channel -> - val showStream = isInPipMode || !isKeyboardVisible || isLandscape + val showStream = isInPipMode || (!isKeyboardVisible && !isEmoteMenuOpen) || isLandscape var streamComposed by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } LaunchedEffect(showStream) { if (showStream) { 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 index 14a5e926d..16c94143f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt @@ -19,37 +19,31 @@ internal class StreamToolbarState( ) { var heightDp by mutableStateOf(0.dp) private var prevHasVisibleStream by mutableStateOf(false) - private var isKeyboardClosingWithStream by mutableStateOf(false) - private var wasKeyboardClosingWithStream by mutableStateOf(false) val hasVisibleStream: Boolean get() = heightDp > 0.dp val effectiveAlpha: Float - get() = if (hasVisibleStream || isKeyboardClosingWithStream || wasKeyboardClosingWithStream) alpha.value else 1f - - suspend fun updateAnimation( - hasVisibleStream: Boolean, - keyboardClosingWithStream: Boolean, - ) { - isKeyboardClosingWithStream = keyboardClosingWithStream - if (keyboardClosingWithStream) wasKeyboardClosingWithStream = true - if (hasVisibleStream) wasKeyboardClosingWithStream = false + get() = alpha.value + suspend fun updateAnimation(hasVisibleStream: Boolean) { when { - keyboardClosingWithStream -> { - alpha.animateTo(0f, tween(durationMillis = 150)) - } - hasVisibleStream && !prevHasVisibleStream -> { prevHasVisibleStream = true - alpha.snapTo(0f) - alpha.animateTo(1f, tween(durationMillis = 350)) + // 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 - alpha.snapTo(0f) + // 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)) } } } @@ -58,19 +52,19 @@ internal class StreamToolbarState( @Composable internal fun rememberStreamToolbarState( currentStream: UserName?, - isKeyboardVisible: Boolean, - imeTargetBottom: Int, ): StreamToolbarState { - val state = remember { StreamToolbarState(alpha = Animatable(0f)) } + val state = remember { StreamToolbarState(alpha = Animatable(1f)) } val hasVisibleStream = currentStream != null && state.heightDp > 0.dp - val isKeyboardClosingWithStream = currentStream != null && isKeyboardVisible && imeTargetBottom == 0 - LaunchedEffect(hasVisibleStream, isKeyboardClosingWithStream) { - state.updateAnimation(hasVisibleStream, isKeyboardClosingWithStream) + LaunchedEffect(hasVisibleStream) { + state.updateAnimation(hasVisibleStream) } LaunchedEffect(currentStream) { - if (currentStream == null) state.heightDp = 0.dp + if (currentStream == null) { + state.heightDp = 0.dp + state.alpha.snapTo(1f) + } } return state From ea2f9b444129f873fb7dee34dd5646ba76545ffd Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 14:32:10 +0200 Subject: [PATCH 204/349] fix(search): Reset search filter when reopening message history --- .../flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt | 3 +++ .../com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index afea9276c..27b1830d2 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -136,6 +137,8 @@ class MessageHistoryViewModel( replace(0, length, normalizedQuery) placeCursorAtEnd() } + } else { + searchFieldState.clearText() } } 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 index 7aa9ed815..dc0735791 100644 --- 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 @@ -100,7 +100,6 @@ fun FullScreenSheetOverlay( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, userLongClickBehavior = userLongClickBehavior, - bottomContentPadding = bottomContentPadding, ) } } @@ -117,7 +116,6 @@ private fun HistorySheetContent( onMessageLongClick: (MessageOptionsParams) -> Unit, onEmoteClick: (List) -> Unit, userLongClickBehavior: UserLongClickBehavior, - bottomContentPadding: Dp, ) { val viewModel: MessageHistoryViewModel = koinViewModel( From bc3c0669930c9073ab87aa761b6e93ac8f156870 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 14:40:14 +0200 Subject: [PATCH 205/349] fix(detekt): Remove ViewModel forwarding, apply modifier at root in WideSplitLayout --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 36 +++++++++---------- .../dankchat/ui/main/StreamToolbarState.kt | 4 +-- .../dankchat/ui/main/stream/StreamView.kt | 3 +- 3 files changed, 20 insertions(+), 23 deletions(-) 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 index b90f1b0f2..db272f580 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -717,10 +717,16 @@ fun MainScreen( ) } + val onStreamClose = { + keyboardController?.hide() + focusManager.clearFocus() + streamViewModel.closeStream() + } + if (useWideSplitLayout) { WideSplitLayout( currentStream = currentStream, - streamViewModel = streamViewModel, + onStreamClose = onStreamClose, scaffoldContent = scaffoldContent, floatingToolbar = floatingToolbar, fullScreenSheetOverlay = fullScreenSheetOverlay, @@ -747,7 +753,8 @@ fun MainScreen( } else { NormalStackedLayout( currentStream = currentStream, - streamViewModel = streamViewModel, + onStreamClose = onStreamClose, + hasWebViewBeenAttached = streamViewModel.hasWebViewBeenAttached, streamState = streamState, scaffoldContent = scaffoldContent, floatingToolbar = floatingToolbar, @@ -783,7 +790,7 @@ fun MainScreen( @Composable private fun BoxScope.WideSplitLayout( currentStream: UserName?, - streamViewModel: StreamViewModel, + onStreamClose: () -> Unit, scaffoldContent: @Composable (PaddingValues, Dp) -> Unit, floatingToolbar: @Composable (Modifier, Boolean, Boolean, Boolean) -> Unit, fullScreenSheetOverlay: @Composable (Dp) -> Unit, @@ -815,7 +822,7 @@ private fun BoxScope.WideSplitLayout( Box( modifier = - Modifier + modifier .fillMaxSize() .onSizeChanged { containerWidthPx = it.width }, ) { @@ -829,13 +836,8 @@ private fun BoxScope.WideSplitLayout( ) { StreamView( channel = currentStream ?: return, - streamViewModel = streamViewModel, fillPane = true, - onClose = { - keyboardController?.hide() - focusManager.clearFocus() - streamViewModel.closeStream() - }, + onClose = onStreamClose, modifier = Modifier.fillMaxSize(), ) } @@ -851,7 +853,7 @@ private fun BoxScope.WideSplitLayout( Scaffold( modifier = - modifier + Modifier .fillMaxSize() .padding(bottom = scaffoldBottomPadding), contentWindowInsets = WindowInsets(0), @@ -936,7 +938,8 @@ private fun BoxScope.WideSplitLayout( @Composable private fun BoxScope.NormalStackedLayout( currentStream: UserName?, - streamViewModel: StreamViewModel, + onStreamClose: () -> Unit, + hasWebViewBeenAttached: Boolean, streamState: StreamToolbarState, scaffoldContent: @Composable (PaddingValues, Dp) -> Unit, floatingToolbar: @Composable (Modifier, Boolean, Boolean, Boolean) -> Unit, @@ -990,7 +993,7 @@ private fun BoxScope.NormalStackedLayout( // Stream View layer currentStream?.let { channel -> val showStream = isInPipMode || (!isKeyboardVisible && !isEmoteMenuOpen) || isLandscape - var streamComposed by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } + var streamComposed by remember { mutableStateOf(hasWebViewBeenAttached) } LaunchedEffect(showStream) { if (showStream) { delay(100) @@ -1002,13 +1005,8 @@ private fun BoxScope.NormalStackedLayout( if (showStream && streamComposed) { StreamView( channel = channel, - streamViewModel = streamViewModel, isInPipMode = isInPipMode, - onClose = { - keyboardController?.hide() - focusManager.clearFocus() - streamViewModel.closeStream() - }, + onClose = onStreamClose, modifier = if (isInPipMode) { Modifier.fillMaxSize() 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 index 16c94143f..98095a05a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt @@ -50,9 +50,7 @@ internal class StreamToolbarState( } @Composable -internal fun rememberStreamToolbarState( - currentStream: UserName?, -): StreamToolbarState { +internal fun rememberStreamToolbarState(currentStream: UserName?): StreamToolbarState { val state = remember { StreamToolbarState(alpha = Animatable(1f)) } val hasVisibleStream = currentStream != null && state.heightDp > 0.dp 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 index 20230049b..4ff63b0a6 100644 --- 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 @@ -34,17 +34,18 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.doOnAttach import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName +import org.koin.compose.viewmodel.koinViewModel @Suppress("LambdaParameterEventTrailing") @Composable fun StreamView( channel: UserName, - streamViewModel: StreamViewModel, modifier: Modifier = Modifier, isInPipMode: Boolean = false, fillPane: Boolean = false, onClose: () -> Unit, ) { + 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). From fa4c935711cbab961cb39e0926f17bb51ed01bf7 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 14:47:32 +0200 Subject: [PATCH 206/349] fix(stream): Keep WebView in composition when hidden to avoid audio interruption --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) 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 index db272f580..fac1b2137 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -990,7 +990,8 @@ private fun BoxScope.NormalStackedLayout( } } - // Stream View layer + // Stream View layer — kept in composition when hidden so the WebView + // stays attached and audio/video continues playing without re-buffering. currentStream?.let { channel -> val showStream = isInPipMode || (!isKeyboardVisible && !isEmoteMenuOpen) || isLandscape var streamComposed by remember { mutableStateOf(hasWebViewBeenAttached) } @@ -998,26 +999,35 @@ private fun BoxScope.NormalStackedLayout( if (showStream) { delay(100) streamComposed = true - } else { - streamComposed = false } } - if (showStream && streamComposed) { + if (streamComposed) { StreamView( channel = channel, isInPipMode = isInPipMode, onClose = onStreamClose, modifier = - if (isInPipMode) { - Modifier.fillMaxSize() - } else { - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .graphicsLayer { alpha = streamState.alpha.value } - .onSizeChanged { size -> - streamState.heightDp = with(density) { size.height.toDp() } - } + 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 } + } }, ) } From 667abbccda2ca6c9966facd07a4591b98de2caa0 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 14:52:05 +0200 Subject: [PATCH 207/349] fix(input): Keep focus when switching from keyboard to emote menu --- .../main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index fac1b2137..3c6482d37 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -1204,11 +1204,13 @@ private fun MainScreenFocusEffects( ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current + val emoteMenuOpenState = rememberUpdatedState(isEmoteMenuOpen) - // Clear focus when keyboard fully reaches the bottom, but not when - // switching to the emote menu. Debounced to avoid premature focus loss. + // 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 && !isEmoteMenuOpen } + snapshotFlow { imeHeight.value == 0 && !emoteMenuOpenState.value } .debounce(150) .distinctUntilChanged() .collect { shouldClearFocus -> From 03594c70341ca7a390423e9c1701094cf6fa7810 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 15:19:11 +0200 Subject: [PATCH 208/349] fix(input): Prevent layout bounce when TextField label animates between states --- .../dankchat/ui/main/input/ChatInputLayout.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 index 458cf8005..8f7b68e3d 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -73,6 +74,7 @@ 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 @@ -86,6 +88,7 @@ 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.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -234,15 +237,20 @@ fun ChatInputLayout( } // Text Field + val density = LocalDensity.current + var singleLineHeight by remember { mutableIntStateOf(0) } TextField( state = textFieldState, enabled = enabled && !tourState.isTourActive, modifier = Modifier .fillMaxWidth() - .focusRequester(focusRequester) - .padding(bottom = 0.dp), - // Reduce bottom padding as actions are below + .defaultMinSize(minHeight = with(density) { singleLineHeight.toDp() }) + .onSizeChanged { size -> + if (textFieldState.text.isEmpty()) { + singleLineHeight = maxOf(singleLineHeight, size.height) + } + }.focusRequester(focusRequester), label = { Text(hint) }, suffix = { Row( From cf786cded5abc720170e6a1e682c5092e2f4c041 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 15:41:49 +0200 Subject: [PATCH 209/349] fix(color): Use nullable color to distinguish unset from user-chosen colors --- .../data/repo/chat/ChatEventProcessor.kt | 7 +++--- .../dankchat/data/repo/chat/ChatRepository.kt | 5 ++--- .../data/repo/chat/RecentMessagesHandler.kt | 5 +++-- .../data/twitch/message/AutomodMessage.kt | 2 +- .../data/twitch/message/PrivMessage.kt | 10 ++------- .../data/twitch/message/UserDisplay.kt | 6 ----- .../data/twitch/message/WhisperMessage.kt | 22 +++++-------------- .../dankchat/ui/chat/ChatMessageMapper.kt | 9 ++++---- 8 files changed, 22 insertions(+), 44 deletions(-) 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 index f4e638296..62a6e05b9 100644 --- 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 @@ -232,7 +232,7 @@ class ChatEventProcessor( 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) ?: Message.DEFAULT_COLOR + val userColor = usersRepository.getCachedUserColor(data.userLogin) val automodBadge = Badge.GlobalBadge( title = "AutoMod", @@ -539,8 +539,9 @@ class ChatEventProcessor( return } - if (message.color != Message.DEFAULT_COLOR) { - usersRepository.cacheUserColor(message.name, message.color) + val color = message.color + if (color != null) { + usersRepository.cacheUserColor(message.name, color) } if (message.name == authDataStore.userName) { 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 a32f79062..30138b441 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 @@ -8,7 +8,6 @@ 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.toUserName -import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.SystemMessageType import com.flxrs.dankchat.data.twitch.message.WhisperMessage import com.flxrs.dankchat.data.twitch.message.toChatItem @@ -102,9 +101,9 @@ class ChatRepository( userId = userState.userId, name = name, displayName = displayName, - color = userState.color?.let(Color::parseColor) ?: Message.DEFAULT_COLOR, + color = userState.color?.let(Color::parseColor), recipientId = null, - recipientColor = Message.DEFAULT_COLOR, + recipientColor = null, recipientName = split[1].toUserName(), recipientDisplayName = split[1].toDisplayName(), message = message, 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 index e113d6119..b2eb4875b 100644 --- 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 @@ -103,8 +103,9 @@ class RecentMessagesHandler( if (message is PrivMessage) { val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() userSuggestions += message.name.lowercase() to userForSuggestion - if (message.color != Message.DEFAULT_COLOR) { - usersRepository.cacheUserColor(message.name, message.color) + val color = message.color + if (color != null) { + usersRepository.cacheUserColor(message.name, color) } } 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 index 9ad457540..f7525eeb3 100644 --- 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 @@ -16,7 +16,7 @@ data class AutomodMessage( val messageText: String?, val reason: TextResource, val badges: List = emptyList(), - val color: Int = DEFAULT_COLOR, + val color: Int? = null, val status: Status = Status.Pending, val isUserSide: Boolean = false, ) : Message() { 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 3fd81d50a..6d0f2bc5f 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,6 @@ 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 java.util.UUID data class PrivMessage( @@ -23,7 +21,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(), @@ -54,7 +52,7 @@ data class PrivMessage( } 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 @@ -114,7 +112,3 @@ val PrivMessage.isElevatedMessage: Boolean /** 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/UserDisplay.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserDisplay.kt index 8d13c54ea..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,6 +1,5 @@ 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) */ @@ -13,8 +12,3 @@ 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/WhisperMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/WhisperMessage.kt index 7dd1fe51f..6347154f7 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 @@ -12,7 +11,6 @@ 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 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?, @@ -49,8 +47,8 @@ data class WhisperMessage( ): 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 color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) + val recipientColor = recipientColorTag?.let(Color::parseColor) val emoteTag = tags["emotes"] ?: "" val message = params.getOrElse(1) { "" } @@ -76,11 +74,11 @@ data class WhisperMessage( val color = data.tags.color .ifBlank { null } - ?.let(Color::parseColor) ?: DEFAULT_COLOR + ?.let(Color::parseColor) val recipientColor = data.recipient.color .ifBlank { null } - ?.let(Color::parseColor) ?: DEFAULT_COLOR + ?.let(Color::parseColor) val badgeTag = data.tags.badges.joinToString(",") { "${it.id}/${it.version}" } val emotesTag = data.tags.emotes @@ -112,13 +110,5 @@ data class WhisperMessage( 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/ui/chat/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt index 41a80cbdd..9a98594f7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -341,7 +341,6 @@ class ChatMessageMapper( val ircColor = tags["color"]?.ifBlank { null }?.let(android.graphics.Color::parseColor) ?: login?.let { usersRepository.getCachedUserColor(it) } - ?: Message.DEFAULT_COLOR val rawNameColor = resolveNameColor(null, ircColor, tags["user-id"]?.toUserId(), chatSettings) return ChatMessageUiState.UserNoticeMessageUi( @@ -441,7 +440,7 @@ class ChatMessageMapper( ) }.toImmutableList(), userDisplayName = userName.formatWithDisplayName(userDisplayName), - rawNameColor = color, + rawNameColor = color ?: Message.DEFAULT_COLOR, messageText = messageText?.takeIf { it.isNotEmpty() }, reason = reason, status = uiStatus, @@ -716,14 +715,14 @@ class ChatMessageMapper( private fun resolveNameColor( customColor: Int?, - ircColor: Int, + ircColor: Int?, userId: UserId?, chatSettings: ChatSettings, ): Int = when { customColor != null -> customColor - ircColor != Message.DEFAULT_COLOR -> ircColor + ircColor != null -> ircColor chatSettings.colorizeNicknames && userId != null -> getStableColor(userId) - else -> ircColor + else -> Message.DEFAULT_COLOR } data class BackgroundColors( From e0b2f3e5c470266e6e4a03c416ce857365370881 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 15:59:04 +0200 Subject: [PATCH 210/349] feat(fab): Add show input option to recovery FAB menu --- app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt | 4 ++-- app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 5 ++++- app/src/main/res/values-b+zh+Hant+TW/strings.xml | 1 + app/src/main/res/values-be-rBY/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de-rDE/strings.xml | 1 + app/src/main/res/values-en-rAU/strings.xml | 1 + app/src/main/res/values-en-rGB/strings.xml | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu-rHU/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-kk-rKZ/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sr/strings.xml | 1 + app/src/main/res/values-tr-rTR/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 26 files changed, 30 insertions(+), 3 deletions(-) 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 index 61a3425d3..f742d1812 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -42,6 +42,7 @@ 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 @@ -598,10 +599,9 @@ private fun getFabMenuItem( } InputAction.HideInput -> { - null + FabMenuItem(R.string.menu_show_input, Icons.Default.Visibility) } - // Already hidden, no point showing this InputAction.Debug -> { when { debugMode -> FabMenuItem(R.string.input_action_debug, Icons.Default.BugReport) 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 index 3c6482d37..f90bb5dd1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -644,7 +644,10 @@ fun MainScreen( } InputAction.HideInput -> { - mainScreenViewModel.toggleInput() + if (!mainScreenViewModel.uiState.value.showInput) { + mainScreenViewModel.toggleInput() + } + mainScreenViewModel.resetGestureState() } InputAction.Debug -> { 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 85e7df33f..32c35a89f 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 @@ -261,6 +261,7 @@ 全螢幕 退出全螢幕 隱藏輸入框 + 顯示輸入框 頻道管理 最多 %1$d 個動作 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 4d8336e11..23420fef0 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -628,6 +628,7 @@ На ўвесь экран Выйсці з поўнаэкраннага рэжыму Схаваць увод + Паказаць увод Мадэрацыя канала diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index fa28fcbeb..857f9261c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -562,6 +562,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Pantalla completa Surt de la pantalla completa Amaga l\'entrada + Mostra l\'entrada Moderació del canal diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index dd5593b0e..43a8aaca0 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -629,6 +629,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Celá obrazovka Ukončit celou obrazovku Skrýt vstup + Zobrazit vstup Moderování kanálu diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index edbc4bef5..31df92fe4 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -629,6 +629,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Vollbild Vollbild beenden Eingabe ausblenden + Eingabe einblenden Kanalmoderation diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 72a0aa29c..53169772d 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -437,6 +437,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Exit fullscreen Hide input + Show input Channel moderation Search messages Last message diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index f8cd3a43d..f4b67a7fa 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -438,6 +438,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Exit fullscreen Hide input + Show input Channel moderation Search messages Last message diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7384f91aa..571b4f544 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -623,6 +623,7 @@ Fullscreen Exit fullscreen Hide input + Show input Channel moderation diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 3c3ba91cf..01ffe723b 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -638,6 +638,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Pantalla completa Salir de pantalla completa Ocultar entrada + Mostrar entrada Moderación del canal diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 633e8c0d4..9332de19a 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -620,6 +620,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Koko näyttö Poistu koko näytöstä Piilota syöttö + Näytä syöttö Kanavan moderointi diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b5457e2da..c6291bdf8 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -622,6 +622,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Plein écran Quitter le plein écran Masquer la saisie + Afficher la saisie Modération du canal diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 3fdc17afa..93c3a0f8d 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -607,6 +607,7 @@ Teljes képernyő Kilépés a teljes képernyőből Bevitel elrejtése + Bevitel megjelenítése Csatorna moderálás diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 04e11dd0e..f356514d0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -605,6 +605,7 @@ Schermo intero Esci dallo schermo intero Nascondi input + Mostra input Moderazione canale diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 6f4df5dbf..08e5698d7 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -588,6 +588,7 @@ 全画面 全画面を終了 入力欄を非表示 + 入力欄を表示 チャンネルモデレーション diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 11466b8b8..fe775ec7c 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -259,6 +259,7 @@ Толық экран Толық экраннан шығу Енгізуді жасыру + Енгізуді көрсету Арна модерациясы Ең көбі %1$d әрекет diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 3e5735718..107c232b7 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -259,6 +259,7 @@ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ବାହାରନ୍ତୁ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ + ଇନପୁଟ୍ ଦେଖାନ୍ତୁ ଚ୍ୟାନେଲ ମଡରେସନ ସର୍ବାଧିକ %1$d କ୍ରିୟା diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 589d692cd..9b738d35a 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -647,6 +647,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pełny ekran Wyjdź z pełnego ekranu Ukryj pole wpisywania + Pokaż pole wpisywania Moderacja kanału diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 054e7195d..6d7c543c0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -617,6 +617,7 @@ Tela cheia Sair da tela cheia Ocultar entrada + Mostrar entrada Moderação do canal diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 313b29ef6..b7172f167 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -607,6 +607,7 @@ Ecrã inteiro Sair do ecrã inteiro Ocultar entrada + Mostrar entrada Moderação do canal diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 233389281..0cad90099 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -633,6 +633,7 @@ На весь экран Выйти из полноэкранного режима Скрыть ввод + Показать ввод Модерация канала diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 38a52d493..6cbbf366e 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -627,6 +627,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Цео екран Изађи из целог екрана Сакриј унос + Прикажи унос Модерација канала diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 436b65370..628ad040a 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -628,6 +628,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Tam ekran Tam ekrandan çık Girişi gizle + Girişi göster Kanal moderasyonu diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index c5fea9e99..1061e285f 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -630,6 +630,7 @@ На весь екран Вийти з повноекранного режиму Сховати введення + Показати введення Модерація каналу diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9aa6fd436..d26005124 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -289,6 +289,7 @@ Fullscreen Exit fullscreen Hide input + Show input Channel moderation Maximum of %1$d action From 2ca2fb93668beba1c0dce7ca40cf197eaf3167ca Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 16:24:14 +0200 Subject: [PATCH 211/349] feat(appearance): Add option to disable channel swipe navigation --- .../preferences/appearance/AppearanceSettings.kt | 1 + .../preferences/appearance/AppearanceSettingsScreen.kt | 9 +++++++++ .../preferences/appearance/AppearanceSettingsState.kt | 4 ++++ .../appearance/AppearanceSettingsViewModel.kt | 1 + .../main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 2 ++ .../com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt | 2 ++ .../com/flxrs/dankchat/ui/main/MainScreenUiState.kt | 1 + .../com/flxrs/dankchat/ui/main/MainScreenViewModel.kt | 1 + app/src/main/res/values-b+zh+Hant+TW/strings.xml | 2 ++ app/src/main/res/values-be-rBY/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de-rDE/strings.xml | 2 ++ app/src/main/res/values-en-rAU/strings.xml | 2 ++ app/src/main/res/values-en-rGB/strings.xml | 2 ++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-fi-rFI/strings.xml | 2 ++ app/src/main/res/values-fr-rFR/strings.xml | 2 ++ app/src/main/res/values-hu-rHU/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja-rJP/strings.xml | 2 ++ app/src/main/res/values-kk-rKZ/strings.xml | 2 ++ app/src/main/res/values-or-rIN/strings.xml | 2 ++ app/src/main/res/values-pl-rPL/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt-rPT/strings.xml | 2 ++ app/src/main/res/values-ru-rRU/strings.xml | 2 ++ app/src/main/res/values-sr/strings.xml | 2 ++ app/src/main/res/values-tr-rTR/strings.xml | 2 ++ app/src/main/res/values-uk-rUA/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 32 files changed, 69 insertions(+) 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 7130cc1a3..35d3d84fe 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 @@ -26,6 +26,7 @@ data class AppearanceSettings( val showChips: Boolean = true, val showChangelogs: Boolean = true, val showCharacterCounter: Boolean = false, + val swipeNavigation: Boolean = true, val inputActions: List = listOf( InputAction.Stream, 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 index 954034105..57ebb2878 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -46,6 +46,7 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.F 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.SwipeNavigation import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.Theme import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.TrueDarkTheme import com.flxrs.dankchat.preferences.components.NavigationBarSpacer @@ -120,6 +121,7 @@ private fun AppearanceSettingsContent( ComponentsCategory( autoDisableInput = settings.autoDisableInput, showCharacterCounter = settings.showCharacterCounter, + swipeNavigation = settings.swipeNavigation, onInteraction = onInteraction, ) NavigationBarSpacer() @@ -131,6 +133,7 @@ private fun AppearanceSettingsContent( private fun ComponentsCategory( autoDisableInput: Boolean, showCharacterCounter: Boolean, + swipeNavigation: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit, ) { PreferenceCategory( @@ -147,6 +150,12 @@ private fun ComponentsCategory( isChecked = showCharacterCounter, onClick = { onInteraction(ShowCharacterCounter(it)) }, ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_swipe_navigation_title), + summary = stringResource(R.string.preference_swipe_navigation_summary), + isChecked = swipeNavigation, + onClick = { onInteraction(SwipeNavigation(it)) }, + ) } } 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 index 7619c11ca..747bfe285 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -38,6 +38,10 @@ sealed interface AppearanceSettingsInteraction { data class ShowCharacterCounter( val value: Boolean, ) : AppearanceSettingsInteraction + + data class SwipeNavigation( + val value: Boolean, + ) : AppearanceSettingsInteraction } @Immutable 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 3a732e34e..50c31685a 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 @@ -35,6 +35,7 @@ class AppearanceSettingsViewModel( 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.SwipeNavigation -> dataStore.update { it.copy(swipeNavigation = interaction.value) } } } } 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 index f90bb5dd1..76b015898 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -278,6 +278,7 @@ fun MainScreen( val isFullscreen = mainState.isFullscreen val effectiveShowInput = mainState.effectiveShowInput + val swipeNavigation = mainState.swipeNavigation val effectiveShowAppBar = mainState.effectiveShowAppBar val toolbarTracker = @@ -678,6 +679,7 @@ fun MainScreen( isLoggedIn = isLoggedIn, effectiveShowInput = effectiveShowInput, isFullscreen = isFullscreen, + swipeNavigation = swipeNavigation, isSheetOpen = isSheetOpen, inputHeightDp = inputHeightDp, helperTextHeightDp = helperTextHeightDp, 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 index 1afbda2b8..52207d66f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -60,6 +60,7 @@ internal fun MainScreenPagerContent( isLoggedIn: Boolean, effectiveShowInput: Boolean, isFullscreen: Boolean, + swipeNavigation: Boolean, isSheetOpen: Boolean, inputHeightDp: Dp, helperTextHeightDp: Dp, @@ -105,6 +106,7 @@ internal fun MainScreenPagerContent( HorizontalPager( state = composePagerState, modifier = Modifier.fillMaxSize(), + userScrollEnabled = swipeNavigation, key = { index -> pagerState.channels.getOrNull(index)?.value ?: index }, ) { page -> if (page in pagerState.channels.indices) { 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 index 5274426b5..680f13088 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt @@ -13,6 +13,7 @@ data class MainScreenUiState( val showCharacterCounter: Boolean = false, val isRepeatedSendEnabled: Boolean = false, val debugMode: Boolean = false, + val swipeNavigation: Boolean = true, val gestureInputHidden: Boolean = false, val gestureToolbarHidden: Boolean = false, ) { 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 index 4c69b9053..a625dd071 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -58,6 +58,7 @@ class MainScreenViewModel( showCharacterCounter = appearance.showCharacterCounter, isRepeatedSendEnabled = developerSettings.repeatedSending, debugMode = developerSettings.debugMode, + swipeNavigation = appearance.swipeNavigation, gestureInputHidden = gestureInputHidden, gestureToolbarHidden = gestureToolbarHidden, ) 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 32c35a89f..482466c80 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 @@ -262,6 +262,8 @@ 退出全螢幕 隱藏輸入框 顯示輸入框 + 頻道滑動導航 + 在聊天中滑動切換頻道 頻道管理 最多 %1$d 個動作 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 23420fef0..40ea62a32 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -629,6 +629,8 @@ Выйсці з поўнаэкраннага рэжыму Схаваць увод Паказаць увод + Навігацыя каналаў свайпам + Пераключэнне каналаў свайпам па чаце Мадэрацыя канала diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 857f9261c..86f997289 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -563,6 +563,8 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 43a8aaca0..2fca28f41 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -630,6 +630,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 31df92fe4..d3a1e2ca5 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -630,6 +630,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Vollbild beenden Eingabe ausblenden Eingabe einblenden + Kanal-Wischnavigation + Kanäle durch Wischen im Chat wechseln Kanalmoderation diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 53169772d..eb1688a58 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -438,6 +438,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Exit fullscreen Hide input Show input + Channel swipe navigation + Switch channels by swiping on the chat Channel moderation Search messages Last message diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index f4b67a7fa..d6d62887b 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -439,6 +439,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Exit fullscreen Hide input Show input + Channel swipe navigation + Switch channels by swiping on the chat Channel moderation Search messages Last message diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 571b4f544..4c58e1eff 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -624,6 +624,8 @@ Exit fullscreen Hide input Show input + Channel swipe navigation + Switch channels by swiping on the chat Channel moderation diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 01ffe723b..a00db0e24 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -639,6 +639,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 9332de19a..5ea3f3b6b 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -621,6 +621,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Poistu koko näytöstä Piilota syöttö Näytä syöttö + Kanavien pyyhkäisynavigaatio + Vaihda kanavaa pyyhkäisemällä chatissa Kanavan moderointi diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index c6291bdf8..b4ab7c278 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -623,6 +623,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 93c3a0f8d..b78cb72b9 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -608,6 +608,8 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f356514d0..20375cedf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -606,6 +606,8 @@ Esci dallo schermo intero Nascondi input Mostra input + Navigazione canali a scorrimento + Cambia canale scorrendo sulla chat Moderazione canale diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 08e5698d7..ab24c84c6 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -589,6 +589,8 @@ 全画面を終了 入力欄を非表示 入力欄を表示 + チャンネルスワイプナビゲーション + チャットをスワイプしてチャンネルを切り替え チャンネルモデレーション diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index fe775ec7c..742d5d434 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -260,6 +260,8 @@ Толық экраннан шығу Енгізуді жасыру Енгізуді көрсету + Арна сырғыту навигациясы + Чатта сырғыту арқылы арналарды ауыстыру Арна модерациясы Ең көбі %1$d әрекет diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 107c232b7..a9e19ccd0 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -260,6 +260,8 @@ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ବାହାରନ୍ତୁ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ ଇନପୁଟ୍ ଦେଖାନ୍ତୁ + ଚ୍ୟାନେଲ ସ୍ୱାଇପ୍ ନାଭିଗେସନ୍ + ଚାଟରେ ସ୍ୱାଇପ୍ କରି ଚ୍ୟାନେଲ ବଦଳାନ୍ତୁ ଚ୍ୟାନେଲ ମଡରେସନ ସର୍ବାଧିକ %1$d କ୍ରିୟା diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 9b738d35a..2f0c8049f 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -648,6 +648,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6d7c543c0..aa35f1ee9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -618,6 +618,8 @@ Sair da tela cheia Ocultar entrada Mostrar entrada + Navegação de canais por deslize + Trocar de canal deslizando no chat Moderação do canal diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b7172f167..ef5f08aa3 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -608,6 +608,8 @@ Sair do ecrã inteiro Ocultar entrada Mostrar entrada + Navegação de canais por deslize + Trocar de canal deslizando no chat Moderação do canal diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 0cad90099..1a62aef1b 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -634,6 +634,8 @@ Выйти из полноэкранного режима Скрыть ввод Показать ввод + Навигация каналов свайпом + Переключение каналов свайпом по чату Модерация канала diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 6cbbf366e..a37bd632d 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -628,6 +628,8 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Изађи из целог екрана Сакриј унос Прикажи унос + Навигација канала превлачењем + Мењајте канале превлачењем по чату Модерација канала diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 628ad040a..9c6e2880a 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -629,6 +629,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Tam ekrandan çık Girişi gizle Girişi göster + Kanal kaydırma ile gezinme + Sohbette kaydırarak kanal değiştirme Kanal moderasyonu diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 1061e285f..1aeb08159 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -631,6 +631,8 @@ Вийти з повноекранного режиму Сховати введення Показати введення + Навігація каналів свайпом + Перемикання каналів свайпом по чату Модерація каналу diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d26005124..7fa2b6b12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -290,6 +290,8 @@ Exit fullscreen Hide input Show input + Channel swipe navigation + Switch channels by swiping on the chat Channel moderation Maximum of %1$d action From 3202716d8f9c6d7d48c390f5ace09691dcaf45ab Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 16:30:28 +0200 Subject: [PATCH 212/349] fix(input): Respect showCharacterCounter setting for character counter visibility --- .../ui/main/input/ChatInputViewModel.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 index 3928131f8..a5b2b535e 100644 --- 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 @@ -227,9 +227,10 @@ class ChatInputViewModel( chatConnector.getConnectionState(channel) } }, - combine(preferenceStore.isLoggedInFlow, appearanceSettingsDataStore.settings.map { it.autoDisableInput }) { a, b -> a to b }, - ) { text, suggestions, activeChannel, connectionState, (isLoggedIn, autoDisableInput) -> - UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput) + appearanceSettingsDataStore.settings.map { it.autoDisableInput to it.showCharacterCounter }, + preferenceStore.isLoggedInFlow, + ) { text, suggestions, activeChannel, connectionState, (autoDisableInput, showCharacterCounter), isLoggedIn -> + UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput, showCharacterCounter) } val replyStateFlow = @@ -325,10 +326,14 @@ class ChatInputViewModel( helperText = helperText, isWhisperTabActive = isWhisperTabActive, characterCounter = - CharacterCounterState.Visible( - text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", - isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, - ), + when { + deps.showCharacterCounter -> CharacterCounterState.Visible( + text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", + isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, + ) + + else -> CharacterCounterState.Hidden + }, userLongClickBehavior = userLongClickBehavior, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()).also { _uiState = it } @@ -574,6 +579,7 @@ private data class UiDependencies( val connectionState: ConnectionState, val isLoggedIn: Boolean, val autoDisableInput: Boolean, + val showCharacterCounter: Boolean, ) private data class InputOverlayState( From 5eca427ffe8eb0c17ec9fcabf8cfde541f0a8b22 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 16:43:57 +0200 Subject: [PATCH 213/349] refactor(tts): Extract TTS into ChatTTSPlayer, fix broken active channel wiring --- .../data/notification/ChatTTSPlayer.kt | 202 ++++++++++++++++++ .../data/notification/NotificationService.kt | 190 ++-------------- .../data/repo/chat/ChatEventProcessor.kt | 4 +- .../repo/chat/ChatNotificationRepository.kt | 8 +- .../flxrs/dankchat/ui/main/MainActivity.kt | 7 +- 5 files changed, 225 insertions(+), 186 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt 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..d74e876b3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt @@ -0,0 +1,202 @@ +package com.flxrs.dankchat.data.notification + +import android.content.Context +import android.media.AudioManager +import android.speech.tts.TextToSpeech +import android.util.Log +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.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.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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +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 scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var tts: TextToSpeech? = null + private var audioManager: AudioManager? = null + private var previousTTSUser: UserName? = null + private var toolSettings = ToolsSettings() + + fun start() { + toolsSettingsDataStore.ttsEnabled + .onEach { setTTSEnabled(enabled = it) } + .launchIn(scope) + toolsSettingsDataStore.ttsForceEnglishChanged + .onEach { setTTSVoice(forceEnglish = it) } + .launchIn(scope) + toolsSettingsDataStore.settings + .onEach { toolSettings = it } + .launchIn(scope) + + scope.launch { + chatNotificationRepository.messageUpdates.collect { items -> + items.forEach { (message) -> + processMessage(message) + } + } + } + } + + fun shutdown() { + shutdownTTS() + } + + private fun processMessage(message: Message) { + if (!message.shouldPlayTTS()) { + return + } + + val channel = when (message) { + is PrivMessage -> message.channel + is UserNoticeMessage -> message.channel + is NoticeMessage -> message.channel + else -> return + } + + val activeChannel = chatChannelProvider.activeChannel.value + if (!toolSettings.ttsEnabled || channel != activeChannel || (audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { + return + } + + if (tts == null) { + scope.launch { initTTS() } + return + } + + if (message is PrivMessage && toolSettings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { + return + } + + message.playTTSMessage() + } + + private suspend fun setTTSEnabled(enabled: Boolean) { + when { + enabled -> initTTS() + else -> shutdownTTS() + } + } + + private suspend fun initTTS() { + val forceEnglish = toolsSettingsDataStore.settings.first().ttsForceEnglish + audioManager = context.getSystemService() + tts = TextToSpeech(context) { 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() + scope.launch { + toolsSettingsDataStore.update { it.copy(ttsEnabled = false) } + } + } + + private fun shutdownTTS() { + tts?.shutdown() + tts = null + previousTTSUser = null + audioManager = null + } + + 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 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 { + toolSettings.ttsIgnoreEmotes -> replace(UNICODE_SYMBOL_REGEX, replacement = "") + else -> this + } + + private fun String.filterUrls(): String = when { + toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") + else -> this + } + + 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/NotificationService.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt index 1b3f19d66..beb47fa34 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,10 +5,8 @@ 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.IBinder -import android.speech.tts.TextToSpeech import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat @@ -18,27 +16,16 @@ import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName 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.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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.android.ext.android.inject -import java.util.Locale import kotlin.concurrent.atomics.AtomicInt import kotlin.coroutines.CoroutineContext @@ -49,7 +36,6 @@ class NotificationService : private val manager: NotificationManager by lazy { getSystemService(NOTIFICATION_SERVICE) as NotificationManager } private var notificationsEnabled = false - private var toolSettings = ToolsSettings() private var notificationsJob: Job? = null private val notifications = mutableMapOf>() @@ -57,17 +43,11 @@ class NotificationService : private val chatNotificationRepository: ChatNotificationRepository by inject() private val dataRepository: DataRepository by inject() - private val toolsSettingsDataStore: ToolsSettingsDataStore by inject() private val notificationsSettingsDataStore: NotificationsSettingsDataStore by inject() - private var tts: TextToSpeech? = null - private var audioManager: AudioManager? = null - private var previousTTSUser: UserName? = null - // minSdk 30 guarantees PendingIntent.FLAG_IMMUTABLE support (API 23+) private val pendingIntentFlag: Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - private var activeTTSChannel: UserName? = null private var shouldNotifyOnMention = false override val coroutineContext: CoroutineContext @@ -82,7 +62,6 @@ class NotificationService : override fun onDestroy() { coroutineContext.cancelChildren() manager.cancelAll() - shutdownTTS() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() @@ -107,15 +86,6 @@ class NotificationService : 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) } override fun onStartCommand( @@ -139,8 +109,7 @@ class NotificationService : stopSelf() } - fun setActiveChannel(channel: UserName) { - activeTTSChannel = channel + fun clearNotificationsForChannel(channel: UserName) { val ids = notifications.remove(channel) ids?.forEach { manager.cancel(it) } @@ -154,47 +123,6 @@ class NotificationService : 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) @@ -233,117 +161,26 @@ class NotificationService : notificationsJob?.cancel() notificationsJob = launch { - chatNotificationRepository.notificationsFlow.collect { items -> - items.forEach { (message) -> - if (shouldNotifyOnMention && notificationsEnabled) { - if (!notifiedMessageIds.add(message.id)) { - return@forEach // Already notified for this message - } - if (notifiedMessageIds.size > MAX_NOTIFIED_IDS) { - val iterator = notifiedMessageIds.iterator() - iterator.next() - iterator.remove() - } - 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 - } + chatNotificationRepository.messageUpdates.collect { items -> + if (!shouldNotifyOnMention || !notificationsEnabled) { + return@collect + } - if (!toolSettings.ttsEnabled || channel != activeTTSChannel || (audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { + items.forEach { (message) -> + if (!notifiedMessageIds.add(message.id)) { return@forEach } - - if (tts == null) { - initTTS() + if (notifiedMessageIds.size > MAX_NOTIFIED_IDS) { + val iterator = notifiedMessageIds.iterator() + iterator.next() + iterator.remove() } - - if (message is PrivMessage && toolSettings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { - return@forEach - } - - message.playTTSMessage() + message.toNotificationData()?.createMentionNotification() } } } } - 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 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 - } - - private fun String.filterUrls(): String = when { - toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") - else -> this - } - private fun NotificationData.createMentionNotification() { val pendingStartActivityIntent = Intent(this@NotificationService, MainActivity::class.java).let { @@ -398,9 +235,6 @@ class NotificationService : 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 val notificationId = AtomicInt(42) 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 index 62a6e05b9..72b20a4cb 100644 --- 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 @@ -201,7 +201,7 @@ class ChatEventProcessor( val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) chatNotificationRepository.incrementMentionCount(WhisperMessage.WHISPER_CHANNEL, 1) - chatNotificationRepository.emitNotification(listOf(item)) + chatNotificationRepository.emitMessages(listOf(item)) } private fun handlePubSubModeration(pubSubMessage: PubSubMessage.ModeratorAction) { @@ -481,7 +481,7 @@ class ChatEventProcessor( } chatMessageRepository.addMessages(channel, additionalMessages + items) - chatNotificationRepository.emitNotification(items) + chatNotificationRepository.emitMessages(items) val mentions = items 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 index 2992451f6..4bea8344d 100644 --- 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 @@ -38,7 +38,7 @@ class ChatNotificationRepository( private val _mentions = MutableStateFlow>(persistentListOf()) private val _whispers = MutableStateFlow>(persistentListOf()) - private val _notificationsFlow = MutableSharedFlow>(replay = 0, extraBufferCapacity = 10) + private val _messageUpdates = MutableSharedFlow>(replay = 0, extraBufferCapacity = 10) private val _channelMentionCount = mutableSharedFlowOf(mutableMapOf()) private val _unreadMessagesMap = mutableSharedFlowOf(mutableMapOf()) @@ -47,7 +47,7 @@ class ChatNotificationRepository( .stateIn(scope, SharingStarted.Eagerly, 500) private val scrollBackLength get() = scrollBackLengthFlow.value - val notificationsFlow: SharedFlow> = _notificationsFlow.asSharedFlow() + val messageUpdates: SharedFlow> = _messageUpdates.asSharedFlow() val channelMentionCount: SharedFlow> = _channelMentionCount.asSharedFlow() val unreadMessagesMap: SharedFlow> = _unreadMessagesMap.asSharedFlow() val mentions: StateFlow> = _mentions @@ -97,8 +97,8 @@ class ChatNotificationRepository( } } - fun emitNotification(items: List) { - _notificationsFlow.tryEmit(items) + fun emitMessages(items: List) { + _messageUpdates.tryEmit(items) } fun setUnreadIfInactive(channel: UserName) { 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 index e2ca5e260..08abc2cf5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -43,6 +43,7 @@ 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 @@ -91,6 +92,7 @@ class MainActivity : AppCompatActivity() { 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 pendingChannelsToClear = mutableListOf() private var currentMediaUri: Uri = Uri.EMPTY @@ -508,6 +510,7 @@ class MainActivity : AppCompatActivity() { } private fun startService() { + chatTTSPlayer.start() if (!isBound) { Intent(this, NotificationService::class.java).also { try { @@ -544,7 +547,7 @@ class MainActivity : AppCompatActivity() { } fun clearNotificationsOfChannel(channel: UserName) = when { - isBound && notificationService != null -> notificationService?.setActiveChannel(channel) + isBound && notificationService != null -> notificationService?.clearNotificationsForChannel(channel) else -> pendingChannelsToClear += channel } @@ -632,7 +635,7 @@ class MainActivity : AppCompatActivity() { isBound = true if (pendingChannelsToClear.isNotEmpty()) { - pendingChannelsToClear.forEach { notificationService?.setActiveChannel(it) } + pendingChannelsToClear.forEach { notificationService?.clearNotificationsForChannel(it) } pendingChannelsToClear.clear() } From 4238e8bb2c534c17b7f1d7187763e14809e7f814 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 17:28:05 +0200 Subject: [PATCH 214/349] feat(tts): Add volume slider and audio ducking, decouple service from activity lifecycle --- .../data/notification/ChatTTSPlayer.kt | 265 +++++++++++------- .../data/notification/NotificationService.kt | 94 ++++--- .../preferences/tools/ToolsSettings.kt | 2 + .../preferences/tools/ToolsSettingsScreen.kt | 25 ++ .../preferences/tools/ToolsSettingsState.kt | 10 + .../tools/ToolsSettingsViewModel.kt | 4 + .../flxrs/dankchat/ui/main/MainActivity.kt | 19 +- .../main/res/values-b+zh+Hant+TW/strings.xml | 3 + app/src/main/res/values-be-rBY/strings.xml | 3 + app/src/main/res/values-ca/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en-rAU/strings.xml | 3 + app/src/main/res/values-en-rGB/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 3 + app/src/main/res/values-fi-rFI/strings.xml | 3 + app/src/main/res/values-fr-rFR/strings.xml | 3 + app/src/main/res/values-hu-rHU/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja-rJP/strings.xml | 3 + app/src/main/res/values-kk-rKZ/strings.xml | 3 + app/src/main/res/values-or-rIN/strings.xml | 3 + app/src/main/res/values-pl-rPL/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt-rPT/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values-sr/strings.xml | 3 + app/src/main/res/values-tr-rTR/strings.xml | 3 + app/src/main/res/values-uk-rUA/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 31 files changed, 325 insertions(+), 166 deletions(-) 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 index d74e876b3..cf14ed6ae 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt @@ -1,14 +1,16 @@ 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.util.Log +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.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 @@ -17,12 +19,14 @@ 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.Dispatchers import kotlinx.coroutines.SupervisorJob -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.core.annotation.Single import java.util.Locale @@ -33,43 +37,111 @@ class ChatTTSPlayer( private val chatNotificationRepository: ChatNotificationRepository, private val chatChannelProvider: ChatChannelProvider, private val toolsSettingsDataStore: ToolsSettingsDataStore, + private val appLifecycleListener: AppLifecycleListener, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var tts: TextToSpeech? = null private var audioManager: AudioManager? = null private var previousTTSUser: UserName? = null - private var toolSettings = ToolsSettings() + private var audioFocusRequest: AudioFocusRequest? = null + private var utteranceId = 0 fun start() { - toolsSettingsDataStore.ttsEnabled - .onEach { setTTSEnabled(enabled = it) } - .launchIn(scope) - toolsSettingsDataStore.ttsForceEnglishChanged - .onEach { setTTSVoice(forceEnglish = it) } - .launchIn(scope) - toolsSettingsDataStore.settings - .onEach { toolSettings = it } - .launchIn(scope) - scope.launch { - chatNotificationRepository.messageUpdates.collect { items -> - items.forEach { (message) -> - processMessage(message) + 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) + } } - } } } - fun shutdown() { - shutdownTTS() + 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 processMessage(message: Message) { - if (!message.shouldPlayTTS()) { - return + 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 @@ -77,122 +149,105 @@ class ChatTTSPlayer( else -> return } - val activeChannel = chatChannelProvider.activeChannel.value - if (!toolSettings.ttsEnabled || channel != activeChannel || (audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { + if (!settings.ttsEnabled || tts == null || channel != activeChannel) { return } - if (tts == null) { - scope.launch { initTTS() } + if ((audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { return } - if (message is PrivMessage && toolSettings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { + if (message is PrivMessage && settings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { return } - message.playTTSMessage() - } - - private suspend fun setTTSEnabled(enabled: Boolean) { - when { - enabled -> initTTS() - else -> shutdownTTS() - } - } - - private suspend fun initTTS() { - val forceEnglish = toolsSettingsDataStore.settings.first().ttsForceEnglish - audioManager = context.getSystemService() - tts = TextToSpeech(context) { status -> - when (status) { - TextToSpeech.SUCCESS -> setTTSVoice(forceEnglish = forceEnglish) - else -> shutdownAndDisableTTS() - } - } + playTTSMessage(message, settings) } - private fun setTTSVoice(forceEnglish: Boolean) { - val voice = when { - forceEnglish -> tts?.voices?.find { it.locale == Locale.US && !it.isNetworkConnectionRequired } - else -> tts?.defaultVoice - } + private fun requestAudioFocus() { + val manager = audioManager ?: return + if (audioFocusRequest != null) return - voice?.takeUnless { tts?.setVoice(it) == TextToSpeech.ERROR } ?: shutdownAndDisableTTS() - } + 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() - private fun shutdownAndDisableTTS() { - shutdownTTS() - scope.launch { - toolsSettingsDataStore.update { it.copy(ttsEnabled = false) } + if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + audioFocusRequest = request } } - private fun shutdownTTS() { - tts?.shutdown() - tts = null - previousTTSUser = null - audioManager = null + private fun abandonAudioFocus() { + val request = audioFocusRequest ?: return + audioManager?.abandonAudioFocusRequest(request) + audioFocusRequest = null } - private fun Message.shouldPlayTTS(): Boolean = this is PrivMessage || this is NoticeMessage || this is UserNoticeMessage - - private fun Message.playTTSMessage() { - val message = when (this) { + private fun playTTSMessage( + message: Message, + settings: ToolsSettings, + ) { + val text = when (message) { is UserNoticeMessage -> { - message + message.message } is NoticeMessage -> { - message + message.message } - else -> { - if (this !is PrivMessage) return - val filtered = message - .filterEmotes(emotes) - .filterUnicodeSymbols() - .filterUrls() - - if (filtered.isBlank()) { - return - } + 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 { - toolSettings.ttsMessageFormat == TTSMessageFormat.Message || name == previousTTSUser -> filtered - tts?.voice?.locale?.language == Locale.ENGLISH.language -> "$name said $filtered" - else -> "$name. $filtered" - }.also { previousTTSUser = name } + 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 (toolSettings.ttsPlayMode) { + val queueMode = when (settings.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) - } + if (settings.ttsAudioDucking) { + requestAudioFocus() } - else -> { - this + val params = Bundle().apply { + putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, settings.ttsVolume) } - } - - private fun String.filterUnicodeSymbols(): String = when { - toolSettings.ttsIgnoreEmotes -> replace(UNICODE_SYMBOL_REGEX, replacement = "") - else -> this - } - - private fun String.filterUrls(): String = when { - toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") - else -> this + tts?.speak(text, queueMode, params, "tts_${utteranceId++}") } companion object { 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 beb47fa34..4941df0bb 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 @@ -10,20 +10,23 @@ import android.os.IBinder 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.ChatChannelProvider import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository import com.flxrs.dankchat.data.repo.data.DataRepository import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.ui.main.MainActivity +import com.flxrs.dankchat.utils.AppLifecycleListener +import com.flxrs.dankchat.utils.AppLifecycleListener.AppLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import kotlin.concurrent.atomics.AtomicInt @@ -35,21 +38,17 @@ class NotificationService : private val binder = LocalBinder() private val manager: NotificationManager by lazy { getSystemService(NOTIFICATION_SERVICE) as NotificationManager } - private var notificationsEnabled = false - - private var notificationsJob: Job? = null private val notifications = mutableMapOf>() private val notifiedMessageIds = LinkedHashSet() private val chatNotificationRepository: ChatNotificationRepository by inject() + private val chatChannelProvider: ChatChannelProvider by inject() private val dataRepository: DataRepository by inject() private val notificationsSettingsDataStore: NotificationsSettingsDataStore by inject() + private val appLifecycleListener: AppLifecycleListener by inject() - // minSdk 30 guarantees PendingIntent.FLAG_IMMUTABLE support (API 23+) private val pendingIntentFlag: Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - private var shouldNotifyOnMention = false - override val coroutineContext: CoroutineContext get() = Dispatchers.IO + Job() @@ -70,7 +69,6 @@ class NotificationService : override fun onCreate() { super.onCreate() - // minSdk 30 guarantees notification channel support (API 26+) val name = getString(R.string.app_name) val channel = NotificationChannel(CHANNEL_ID_LOW, name, NotificationManager.IMPORTANCE_LOW).apply { @@ -83,9 +81,46 @@ class NotificationService : manager.createNotificationChannel(mentionChannel) manager.createNotificationChannel(channel) - notificationsSettingsDataStore.showNotifications - .onEach { notificationsEnabled = it } - .launchIn(this) + launch { + appLifecycleListener.appState + .map { it == AppLifecycle.Foreground } + .distinctUntilChanged() + .collect { isForeground -> + if (isForeground) { + notifiedMessageIds.clear() + val activeChannel = chatChannelProvider.activeChannel.value + if (activeChannel != null) { + clearNotificationsForChannel(activeChannel) + } + } + } + } + + launch { + combine( + chatNotificationRepository.messageUpdates, + notificationsSettingsDataStore.showNotifications, + appLifecycleListener.appState, + ) { items, enabled, lifecycle -> + Triple(items, enabled, lifecycle) + }.collect { (items, enabled, lifecycle) -> + if (!enabled || lifecycle != AppLifecycle.Background) { + return@collect + } + + 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( @@ -119,10 +154,6 @@ class NotificationService : } } - fun enableNotifications() { - shouldNotifyOnMention = true - } - private fun startForeground() { val title = getString(R.string.notification_title) val message = getString(R.string.notification_message) @@ -146,7 +177,7 @@ class NotificationService : .setContentTitle(title) .setContentText(message) .addAction(R.drawable.ic_clear, getString(R.string.notification_stop), pendingStopIntent) - .setStyle(MediaStyle().setShowActionsInCompactView(0)) // minSdk 30 guarantees MediaStyle support + .setStyle(MediaStyle().setShowActionsInCompactView(0)) .setContentIntent(pendingStartActivityIntent) .setSmallIcon(R.drawable.ic_notification_icon) .build() @@ -154,33 +185,6 @@ class NotificationService : startForeground(NOTIFICATION_ID, notification) } - fun checkForNotification() { - shouldNotifyOnMention = false - notifiedMessageIds.clear() - - notificationsJob?.cancel() - notificationsJob = - launch { - chatNotificationRepository.messageUpdates.collect { items -> - if (!shouldNotifyOnMention || !notificationsEnabled) { - return@collect - } - - 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() - } - } - } - } - private fun NotificationData.createMentionNotification() { val pendingStartActivityIntent = Intent(this@NotificationService, MainActivity::class.java).let { 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 5d013cd67..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,6 +13,8 @@ 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 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 cd0a15063..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 @@ -47,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 @@ -71,6 +72,7 @@ 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 @@ -81,6 +83,7 @@ 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( @@ -356,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 index 505ba27db..6e86dc220 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt @@ -27,6 +27,14 @@ sealed interface ToolsSettingsInteraction { 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 @@ -41,5 +49,7 @@ data class ToolsSettingsState( 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 7283e3930..b8cce050c 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 @@ -38,6 +38,8 @@ class ToolsSettingsViewModel( 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) } } } @@ -53,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/ui/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt index 08abc2cf5..c525f5ae8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -93,7 +93,6 @@ class MainActivity : AppCompatActivity() { private val onboardingDataStore: OnboardingDataStore by inject() private val dataRepository: DataRepository by inject() private val chatTTSPlayer: ChatTTSPlayer by inject() - private val pendingChannelsToClear = mutableListOf() private var currentMediaUri: Uri = Uri.EMPTY private val requestPermissionLauncher = @@ -178,6 +177,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) + chatTTSPlayer.start() setupComposeUi() intent.parcelable(OPEN_CHANNEL_KEY)?.let { channel -> lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channel)) } @@ -510,7 +510,6 @@ class MainActivity : AppCompatActivity() { } private fun startService() { - chatTTSPlayer.start() if (!isBound) { Intent(this, NotificationService::class.java).also { try { @@ -527,10 +526,6 @@ class MainActivity : AppCompatActivity() { override fun onStop() { super.onStop() if (isBound) { - if (!isChangingConfigurations) { - notificationService?.enableNotifications() - } - isBound = false try { unbindService(twitchServiceConnection) @@ -546,9 +541,8 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channelExtra)) } } - fun clearNotificationsOfChannel(channel: UserName) = when { - isBound && notificationService != null -> notificationService?.clearNotificationsForChannel(channel) - else -> pendingChannelsToClear += channel + fun clearNotificationsOfChannel(channel: UserName) { + notificationService?.clearNotificationsForChannel(channel) } private fun handleShutDown() { @@ -633,13 +627,6 @@ class MainActivity : AppCompatActivity() { val binder = service as NotificationService.LocalBinder notificationService = binder.service isBound = true - - if (pendingChannelsToClear.isNotEmpty()) { - pendingChannelsToClear.forEach { notificationService?.clearNotificationsForChannel(it) } - pendingChannelsToClear.clear() - } - - binder.service.checkForNotification() } override fun onServiceDisconnected(className: ComponentName?) { 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 482466c80..f17498cb8 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 @@ -418,6 +418,9 @@ 忽略 URL 在文字朗讀時忽略表情符號 忽略 表情符號 + 音量 + 音訊閃避 + TTS朗讀時降低其他應用程式的音量 TTS 行列顏色調整 以不同顏色隔開訊息 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 40ea62a32..0d7ef0112 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -285,6 +285,9 @@ Ігнараваць URL Ігнараваць смайлы і эмодзі ў сінтэзатары гаворкі Ігнараваць смайлы + Гучнасць + Прыглушэнне гуку + Змяншаць гучнасць іншых праграм падчас агучкі Сінтэзатар гаворкі Шахматныя лініі Падзяляць кожную лінію, карыстаючыся колерамі фону з рознай яркасцю diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 86f997289..9fa84cd13 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -290,6 +290,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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 2fca28f41..d71501554 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -292,6 +292,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í diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index d3a1e2ca5..204497138 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -282,6 +282,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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index eb1688a58..9a0601179 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -514,6 +514,9 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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. diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index d6d62887b..9e1c5f124 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -515,6 +515,9 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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. diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4c58e1eff..d664ed232 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -275,6 +275,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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index a00db0e24..9372c4486 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -286,6 +286,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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 5ea3f3b6b..6d730d868 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -282,6 +282,9 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b4ab7c278..b153346b0 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -285,6 +285,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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index b78cb72b9..042b89452 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -275,6 +275,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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 20375cedf..229975a51 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -279,6 +279,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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index ab24c84c6..64a3c70f4 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -269,6 +269,9 @@ URLを無視 TTSのエモートと絵文字を無視 エモートを無視 + 音量 + オーディオダッキング + TTS再生中に他のアプリの音量を下げる TTS ラインの色調整 ラインごとに背景の明るさを変更 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 742d5d434..829e5985e 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -416,6 +416,9 @@ URL мекенжайын елемеу TTS-те эмоциялар мен эмотикондарды елемейді Эмоцияларды елемеу + Дыбыс деңгейі + Дыбысты басу + TTS сөйлеп жатқанда басқа қолданбалардың дыбысын азайту TTS Тексерілген жолдар Әр сызықты әр түрлі фондық жарықтықпен сеперациялау diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index a9e19ccd0..5de80e226 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -416,6 +416,9 @@ URL କୁ ଅଗ୍ରାହ୍ୟ କରନ୍ତୁ | TTS ରେ ଇମୋଟ ଏବଂ ଇମୋଜିଗୁଡ଼ିକୁ ଉପେକ୍ଷା କରେ | ଇମୋଟ୍କୁ ଉପେକ୍ଷା କରନ୍ତୁ | + ଧ୍ୱନି + ଅଡିଓ ଡକିଂ + TTS ବୋଲୁଥିବା ସମୟରେ ଅନ୍ୟ ଅଡିଓ ଧ୍ୱନି କମ୍ କରନ୍ତୁ TTS ଚେକେଡ୍ ଲାଇନ୍ସ | ବିଭିନ୍ନ ପୃଷ୍ଠଭୂମି ଉଜ୍ଜ୍ୱଳତା ସହିତ ପ୍ରତ୍ୟେକ ଧାଡି ଅଲଗା କରନ୍ତୁ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 2f0c8049f..d94f17c88 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -289,6 +289,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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index aa35f1ee9..92c5d2510 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -280,6 +280,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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index ef5f08aa3..b8dffb9e3 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -280,6 +280,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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 1a62aef1b..7ff568b16 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -290,6 +290,9 @@ Игнорировать URL Игнорировать смайлы и эмодзи в синтезаторе речи Игнорировать смайлы + Громкость + Приглушение звука + Уменьшать громкость других приложений во время озвучки Синтезатор речи Шахматные линии Разделять каждую линию, используя цвета фона с разной яркостью diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index a37bd632d..81fcd03f5 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -380,6 +380,9 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Игнориши URL адресе Игнорише емотиконе и емоџије у TTS Игнориши емотиконе + Јачина звука + Пригушивање звука + Смањи јачину других апликација док TTS говори Tekst u govor Forsiraj engleski jezik Forisraj čitanje teksta na engleskom umesto na podrazumevanom jeziku diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 9c6e2880a..cd963710e 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -281,6 +281,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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 1aeb08159..1cd972c71 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -292,6 +292,9 @@ Ігнорувати URL-адреси Ігнорує емоції та емодзі в TTS Ігнорувати емоції + Гучність + Приглушення звуку + Зменшувати гучність інших додатків під час озвучення Синтезатор мовлення Строкаті лінії Відокремлювати кожну строку з різною яскравістю фону diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fa2b6b12..5c1c8e296 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -477,6 +477,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 From aafb191be63babd6c9b3eef9b9a3f439bd7f7981 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 17:33:00 +0200 Subject: [PATCH 215/349] ci: Add version tags to signed release for Obtainium compatibility --- .github/workflows/android.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 410016e71..f00a62dec 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -123,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: @@ -136,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 From 04aff1669f942b5e0159bc49b457d7dbef1d05a7 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 17:52:56 +0200 Subject: [PATCH 216/349] fix(chat): Remove annoying no-history system message when history is opted out --- .../flxrs/dankchat/data/repo/chat/ChatRepository.kt | 12 ++---------- .../data/twitch/message/SystemMessageType.kt | 2 -- .../com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt | 4 ---- 3 files changed, 2 insertions(+), 16 deletions(-) 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 30138b441..28f5ca286 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 @@ -124,16 +124,8 @@ class ChatRepository( ) = chatEventProcessor.setLastMessage(channel, message) suspend fun loadRecentMessagesIfEnabled(channel: UserName) { - when { - chatSettingsDataStore.settings.first().loadMessageHistory -> { - chatEventProcessor.loadRecentMessages(channel) - } - - else -> { - chatMessageRepository.getMessagesFlow(channel)?.update { current -> - current + SystemMessageType.NoHistoryLoaded.toChatItem() - } - } + if (chatSettingsDataStore.settings.first().loadMessageHistory) { + chatEventProcessor.loadRecentMessages(channel) } } 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 003e05fc7..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 @@ -13,8 +13,6 @@ sealed interface SystemMessageType { data object Reconnected : SystemMessageType - data object NoHistoryLoaded : SystemMessageType - data object LoginExpired : SystemMessageType data object MessageHistoryIncomplete : SystemMessageType 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 index 9a98594f7..bd02eb916 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -153,10 +153,6 @@ class ChatMessageMapper( TextResource.Res(R.string.system_message_disconnected) } - is SystemMessageType.NoHistoryLoaded -> { - TextResource.Res(R.string.system_message_no_history) - } - is SystemMessageType.Connected -> { TextResource.Res(R.string.system_message_connected) } From d879795e971017bff52117a042166b830341acd6 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 18:14:58 +0200 Subject: [PATCH 217/349] feat(suggestions): Replace boolean toggles with granular suggestion type multi-select --- .../data/repo/command/CommandRepository.kt | 5 +- .../dankchat/preferences/chat/ChatSettings.kt | 19 ++++++- .../preferences/chat/ChatSettingsDataStore.kt | 49 +++++++++++++------ .../preferences/chat/ChatSettingsScreen.kt | 25 +++++----- .../preferences/chat/ChatSettingsState.kt | 11 ++--- .../preferences/chat/ChatSettingsViewModel.kt | 11 ++--- .../ui/chat/suggestion/SuggestionProvider.kt | 40 ++++++++++----- .../ui/main/input/ChatInputViewModel.kt | 13 ++--- .../main/res/values-b+zh+Hant+TW/strings.xml | 8 ++- app/src/main/res/values-be-rBY/strings.xml | 8 ++- app/src/main/res/values-ca/strings.xml | 8 ++- app/src/main/res/values-cs/strings.xml | 8 ++- app/src/main/res/values-de-rDE/strings.xml | 8 ++- app/src/main/res/values-en-rAU/strings.xml | 8 ++- app/src/main/res/values-en-rGB/strings.xml | 8 ++- app/src/main/res/values-en/strings.xml | 8 ++- app/src/main/res/values-es-rES/strings.xml | 8 ++- app/src/main/res/values-fi-rFI/strings.xml | 8 ++- app/src/main/res/values-fr-rFR/strings.xml | 8 ++- app/src/main/res/values-hu-rHU/strings.xml | 8 ++- app/src/main/res/values-it/strings.xml | 8 ++- app/src/main/res/values-ja-rJP/strings.xml | 8 ++- app/src/main/res/values-kk-rKZ/strings.xml | 8 ++- app/src/main/res/values-or-rIN/strings.xml | 8 ++- app/src/main/res/values-pl-rPL/strings.xml | 8 ++- app/src/main/res/values-pt-rBR/strings.xml | 8 ++- app/src/main/res/values-pt-rPT/strings.xml | 8 ++- app/src/main/res/values-ru-rRU/strings.xml | 8 ++- app/src/main/res/values-sr/strings.xml | 8 ++- app/src/main/res/values-tr-rTR/strings.xml | 8 ++- app/src/main/res/values-uk-rUA/strings.xml | 8 ++- app/src/main/res/values/strings.xml | 8 ++- 32 files changed, 251 insertions(+), 114 deletions(-) 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 1ae4303c0..548a68f24 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 @@ -17,6 +17,7 @@ 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.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 @@ -66,7 +67,7 @@ class CommandRepository( init { scope.launch { chatSettingsDataStore.settings - .map { it.supibotSuggestions } + .map { SuggestionType.SupibotCommands in it.suggestionTypes } .distinctUntilChanged() .collect { enabled -> when { @@ -163,7 +164,7 @@ class CommandRepository( } suspend fun loadSupibotCommands() = withContext(Dispatchers.Default) { - if (!authDataStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { + if (!authDataStore.isLoggedIn || SuggestionType.SupibotCommands !in chatSettingsDataStore.settings.first().suggestionTypes) { return@withContext } 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 f38244eee..a9dce3656 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,8 +8,10 @@ import kotlin.uuid.Uuid @Serializable data class ChatSettings( - val suggestions: Boolean = true, - val supibotSuggestions: Boolean = false, + val suggestionTypes: List = SuggestionType.DEFAULT, + 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, @@ -47,6 +49,19 @@ data class CustomCommand( @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, OpensPopup, 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 17d03f65b..99a8d983e 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 @@ -60,11 +60,15 @@ class ChatSettingsDataStore( private val initialMigration = dankChatPreferencesMigration(context) { acc, key, value -> when (key) { - ChatPreferenceKeys.Suggestions -> { + @Suppress("DEPRECATION") + ChatPreferenceKeys.Suggestions, + -> { acc.copy(suggestions = value.booleanOrDefault(acc.suggestions)) } - ChatPreferenceKeys.SupibotSuggestions -> { + @Suppress("DEPRECATION") + ChatPreferenceKeys.SupibotSuggestions, + -> { acc.copy(supibotSuggestions = value.booleanOrDefault(acc.supibotSuggestions)) } @@ -187,6 +191,31 @@ class ChatSettingsDataStore( override suspend fun cleanUp() = Unit } + @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, + ) + } + + override suspend fun cleanUp() = Unit + } + private val dataStore = createDataStore( fileName = "chat", @@ -194,7 +223,7 @@ class ChatSettingsDataStore( defaultValue = ChatSettings(), serializer = ChatSettings.serializer(), scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), - migrations = listOf(initialMigration, scrollbackResetMigration, sharedChatMigration), + migrations = listOf(initialMigration, scrollbackResetMigration, sharedChatMigration, suggestionTypeMigration), ) val settings = dataStore.safeData(ChatSettings()) @@ -209,9 +238,9 @@ class ChatSettingsDataStore( settings .map { it.customCommands } .distinctUntilChanged() - val suggestions = + val suggestionTypes = settings - .map { it.suggestions } + .map { it.suggestionTypes } .distinctUntilChanged() val showChatModes = settings @@ -233,16 +262,6 @@ class ChatSettingsDataStore( .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 - } - fun current() = currentSettings.value suspend fun update(transform: suspend (ChatSettings) -> ChatSettings) { 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 99b5acb25..5a4038b53 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 @@ -129,8 +129,7 @@ private fun ChatSettingsScreen( .verticalScroll(rememberScrollState()), ) { GeneralCategory( - suggestions = settings.suggestions, - supibotSuggestions = settings.supibotSuggestions, + suggestionTypes = settings.suggestionTypes, animateGifs = settings.animateGifs, scrollbackLength = settings.scrollbackLength, showUsernames = settings.showUsernames, @@ -172,8 +171,7 @@ private fun ChatSettingsScreen( @Composable private fun GeneralCategory( - suggestions: Boolean, - supibotSuggestions: Boolean, + suggestionTypes: ImmutableList, animateGifs: Boolean, scrollbackLength: Int, showUsernames: Boolean, @@ -189,16 +187,19 @@ private fun GeneralCategory( onInteraction: (ChatSettingsInteraction) -> Unit, ) { PreferenceCategory(title = stringResource(R.string.preference_general_header)) { - SwitchPreferenceItem( + 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() + PreferenceMultiListDialog( title = stringResource(R.string.preference_suggestions_title), summary = stringResource(R.string.preference_suggestions_summary), - isChecked = suggestions, - onClick = { onInteraction(ChatSettingsInteraction.Suggestions(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_supibot_suggestions_title), - isChecked = supibotSuggestions, - onClick = { onInteraction(ChatSettingsInteraction.SupibotSuggestions(it)) }, + values = remember { SuggestionType.entries.toImmutableList() }, + initialSelected = suggestionTypes, + entries = suggestionEntries, + onChange = { onInteraction(ChatSettingsInteraction.SuggestionTypes(it)) }, ) PreferenceItem( title = stringResource(R.string.commands_title), 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 index 4a8703a6d..23b7366f4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt @@ -8,12 +8,8 @@ sealed interface ChatSettingsEvent { } sealed interface ChatSettingsInteraction { - data class Suggestions( - val value: Boolean, - ) : ChatSettingsInteraction - - data class SupibotSuggestions( - val value: Boolean, + data class SuggestionTypes( + val value: List, ) : ChatSettingsInteraction data class CustomCommands( @@ -87,8 +83,7 @@ sealed interface ChatSettingsInteraction { @Immutable data class ChatSettingsState( - val suggestions: Boolean, - val supibotSuggestions: Boolean, + val suggestionTypes: ImmutableList, val customCommands: ImmutableList, val animateGifs: Boolean, val scrollbackLength: Int, 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 b372a4735..abd73b860 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 @@ -33,12 +33,8 @@ class ChatSettingsViewModel( fun onInteraction(interaction: ChatSettingsInteraction) = viewModelScope.launch { runCatching { when (interaction) { - is ChatSettingsInteraction.Suggestions -> { - chatSettingsDataStore.update { it.copy(suggestions = interaction.value) } - } - - is ChatSettingsInteraction.SupibotSuggestions -> { - chatSettingsDataStore.update { it.copy(supibotSuggestions = interaction.value) } + is ChatSettingsInteraction.SuggestionTypes -> { + chatSettingsDataStore.update { it.copy(suggestionTypes = interaction.value) } } is ChatSettingsInteraction.CustomCommands -> { @@ -120,8 +116,7 @@ class ChatSettingsViewModel( } private fun ChatSettings.toState() = ChatSettingsState( - suggestions = suggestions, - supibotSuggestions = supibotSuggestions, + suggestionTypes = suggestionTypes.toImmutableList(), customCommands = customCommands.toImmutableList(), animateGifs = animateGifs, scrollbackLength = scrollbackLength, 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 index b0a3981cc..2bb211e39 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -26,8 +27,9 @@ class SuggestionProvider( inputText: String, cursorPosition: Int, channel: UserName?, + enabledTypes: List, ): Flow> { - if (inputText.isBlank() || channel == null) { + if (inputText.isBlank() || channel == null || enabledTypes.isEmpty()) { return flowOf(emptyList()) } @@ -36,6 +38,11 @@ class SuggestionProvider( 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 = @@ -51,7 +58,7 @@ class SuggestionProvider( return flowOf(emptyList()) } - if (isEmoteTrigger) { + if (isEmoteTrigger && emotesEnabled) { val emojiResults = filterEmojis(emojiRepository.emojis.value, emoteQuery) return getScoredEmoteSuggestions(channel, emoteQuery).map { emoteResults -> mergeSorted(emoteResults, emojiResults) @@ -59,7 +66,7 @@ class SuggestionProvider( } // '@' trigger: users only - if (currentWord.startsWith('@')) { + if (currentWord.startsWith('@') && usersEnabled) { return getUserSuggestions(channel, currentWord).map { users -> users.take(MAX_SUGGESTIONS) } @@ -67,17 +74,22 @@ class SuggestionProvider( // Commands only when prefix matches a command trigger character val isCommandTrigger = currentWord.startsWith('/') || currentWord.startsWith('$') - if (isCommandTrigger) { - return getCommandSuggestions(channel, currentWord).map { commands -> + if (isCommandTrigger && (commandsEnabled || supibotEnabled)) { + return getCommandSuggestions(channel, currentWord, commandsEnabled, supibotEnabled).map { commands -> commands.take(MAX_SUGGESTIONS) } } - // General: score emotes + users together, emotes slightly preferred - return combine( - getScoredEmoteSuggestions(channel, currentWord), - getScoredUserSuggestions(channel, currentWord), - ) { emotes, users -> + // General: score enabled types together + val emoteFlow = when { + emotesEnabled -> getScoredEmoteSuggestions(channel, currentWord) + else -> flowOf(emptyList()) + } + val userFlow = when { + usersEnabled -> getScoredUserSuggestions(channel, currentWord) + else -> flowOf(emptyList()) + } + return combine(emoteFlow, userFlow) { emotes, users -> mergeSorted(emotes, users) } } @@ -107,11 +119,17 @@ class SuggestionProvider( private fun getCommandSuggestions( channel: UserName, constraint: String, + commandsEnabled: Boolean, + supibotEnabled: Boolean, ): Flow> = combine( commandRepository.getCommandTriggers(channel), commandRepository.getSupibotCommands(channel), ) { triggers, supibotCommands -> - filterCommands(triggers + supibotCommands, constraint) + 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 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 index a5b2b535e..83427cd20 100644 --- 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 @@ -111,15 +111,12 @@ class ChatInputViewModel( combine( debouncedTextAndCursor, chatChannelProvider.activeChannel, - chatSettingsDataStore.suggestions, - ) { (text, cursorPos), channel, enabled -> - Triple(text, cursorPos, channel) to enabled - }.flatMapLatest { (triple, enabled) -> + chatSettingsDataStore.suggestionTypes, + ) { (text, cursorPos), channel, enabledTypes -> + Triple(text, cursorPos, channel) to enabledTypes + }.flatMapLatest { (triple, enabledTypes) -> val (text, cursorPos, channel) = triple - when { - enabled -> suggestionProvider.getSuggestions(text, cursorPos, channel) - else -> flowOf(emptyList()) - } + suggestionProvider.getSuggestions(text, cursorPos, channel, enabledTypes) }.map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) 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 f17498cb8..a83d387c0 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 @@ -393,8 +393,12 @@ 非常大 - 自動完成表情符號及使用者 - 於輸入訊息時,依照輸入字串顯示相關表情符號以及使用者名 + 建議 + 選擇輸入時顯示的建議類型 + 表情 + 用戶 + 指令 + Supibot指令 啟動時自動讀取聊天紀錄 在重新連接後載入訊息歷史 嘗試捕捉在連接斷開時漏掉的訊息 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 0d7ef0112..1cfb3390e 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -260,8 +260,12 @@ Маленькі Вялікі Вялізны - Падказкі карыстальнікаў і смайлаў - Адлюстроўваць падказкі для нікаў карыстальнікаў і смайлаў пры наборы паведамленняў + Падказкі + Абярыце, якія падказкі паказваць пры ўводзе + Эмоцыі + Карыстальнікі + Каманды + Каманды Supibot Загружаць гісторыю паведамленняў адразу Загрузіць гісторыю паведамленняў пасля паўторнага падключэння Спрабаваць атрымаць прапушчаныя паведамленні, якія не былі атрыманы падчас разрыву злучэння diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9fa84cd13..8b5b1be83 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -265,8 +265,12 @@ 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 + Usuaris + Ordres + Ordres de Supibot 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ó diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d71501554..f051f8720 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -267,8 +267,12 @@ 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 + Příkazy Supibota 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í diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 204497138..e48bf17d0 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -257,8 +257,12 @@ 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 + Befehle + Supibot-Befehle Chatverlauf laden Nachrichtenverlauf nach Verbindungsabbrüchen neu laden Versucht, verpasste Nachrichten zu laden, die bei Verbindungsabbrüchen verloren gingen diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 9a0601179..97b3c44da 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -222,8 +222,12 @@ 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 + Commands + Supibot commands Load message history on start Message history Open dashboard diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 9e1c5f124..40e828d05 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -222,8 +222,12 @@ 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 + Commands + Supibot commands Load message history on start Message history Open dashboard diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d664ed232..c38b6033c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -250,8 +250,12 @@ 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 + Commands + Supibot commands Load message history on start Load message history after a reconnect Attempts to fetch missed messages that were not received during connection drops diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 9372c4486..362a0a79e 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -261,8 +261,12 @@ 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 + Comandos de Supibot 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 6d730d868..5da12ceba 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -257,8 +257,12 @@ 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 + Komennot + Supibot-komennot Lataa viestihistoria käynnistyessä Lataa viestihistoria uudelleenyhdistyksen jälkeen Yrittää hakea puuttuvat viestit, joita ei vastaanotettu yhteyskatkosten aikana diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b153346b0..2ea7d9b4d 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -260,8 +260,12 @@ 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 + Commandes Supibot 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 042b89452..84648acbf 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -250,8 +250,12 @@ 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 + Parancsok + Supibot parancsok Ü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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 229975a51..4f3c5252e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -254,8 +254,12 @@ 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 + Comandi Supibot Carica cronologia messaggi, all\'avvio Carica la cronologia dei messaggi dopo una riconnessone Tenta di recuperare i messaggi persi durante un interruzione della connessione diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 64a3c70f4..2d70c5e2b 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -244,8 +244,12 @@ 極大 - エモートとユーザーの候補 - 入力中にエモートとアクティブユーザーの候補を表示する + サジェスト + 入力中に表示するサジェストを選択 + エモート + ユーザー + コマンド + Supibotコマンド 開始時にメッセージ履歴を読み込む 再接続後にメッセージ履歴を読み込む 接続が切断中に受信されずに失われたメッセージを取得しようとしています diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 829e5985e..1bf2cebae 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -391,8 +391,12 @@ Кіші Үлкен Өте үлкен - Эмоция және пайдаланушы ұсыныстары - Теру кезінде эмоциялар мен белсенді пайдаланушыларға арналған ұсыныстарды көрсетеді + Ұсыныстар + Теру кезінде қандай ұсыныстарды көрсету керектігін таңдаңыз + Эмоттар + Пайдаланушылар + Командалар + Supibot командалары Хабар журналын бастауға жүктеу Қайта қосылғаннан кейін хабар журналын жүктеңіз Байланыс үзілген кезде қабылданбаған хабарларды алу әрекеті diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 5de80e226..d355165fa 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -391,8 +391,12 @@ ଛୋଟ ବଡ଼ ବହୁତ ବଡ଼ - ଇମୋଟ୍ ଏବଂ ଉପଭୋକ୍ତା ପରାମର୍ଶ | - ଟାଇପ୍ କରିବା ସମୟରେ ଇମୋଟ ଏବଂ ସକ୍ରିୟ ଉପଭୋକ୍ତାମାନଙ୍କ ପାଇଁ ପରାମର୍ଶ ଦେଖାଏ | + ପରାମର୍ଶ + ଟାଇପ୍ କରିବା ସମୟରେ କେଉଁ ପରାମର୍ଶ ଦେଖାଇବେ ବାଛନ୍ତୁ + ଇମୋଟ + ଉପଭୋକ୍ତା + କಮାଣ୍ଡ + Supibot କମାଣ୍ଡ ଆରମ୍ଭରେ ବାର୍ତ୍ତା ଇତିହାସ ଲୋଡ୍ କରନ୍ତୁ | ପୁନ recon ସଂଯୋଗ ପରେ ବାର୍ତ୍ତା ଇତିହାସ ଲୋଡ୍ କରନ୍ତୁ | ମିସ୍ ଡ୍ରପ୍ ସମୟରେ ଗ୍ରହଣ ହୋଇନଥିବା ସନ୍ଦେଶ ଆଣିବାକୁ ଚେଷ୍ଟା କରିବାକୁ ଚେଷ୍ଟା କରେ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index d94f17c88..090c0666a 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -264,8 +264,12 @@ 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 + Komendy Supibota Ł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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 92c5d2510..b5cffae1a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -255,8 +255,12 @@ 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 + Comandos do Supibot 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b8dffb9e3..6229f7d30 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -255,8 +255,12 @@ 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 + Comandos do Supibot 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 7ff568b16..a47f6812e 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -265,8 +265,12 @@ Маленький Большой Огромный - Подсказки пользователей и смайлов - Отображать подсказки для имён пользователей и смайлов при наборе сообщения + Подсказки + Выберите, какие подсказки показывать при вводе + Эмоции + Пользователи + Команды + Команды Supibot Загружать историю сообщений сразу Загружать историю сообщений после переподключения Попытаться получить пропущенные сообщения, которые не были получены во время разрыва соединения diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 81fcd03f5..416ba07fe 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -355,8 +355,12 @@ 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 + Предлози + Изаберите које предлоге приказати приликом куцања + Емоте + Корисници + Команде + Supibot команде Učitaj istoriju poruka na početku Учитај историју порука после поновног повезивања Покушава да преузме пропуштене поруке које нису примљене током прекида везе diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index cd963710e..eccc73b13 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -256,8 +256,12 @@ 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 + Komutlar + Supibot komutları 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 1cd972c71..da4cc320a 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -267,8 +267,12 @@ Маленький Великий Величезний - Підказки імен користувачів та смайлів - Показувати підказки для імен користувачів та назв смайлів + Підказки + Оберіть, які підказки показувати під час введення + Емоції + Користувачі + Команди + Команди Supibot Завантажувати історію повідомлень при запуску Завантаження історії повідомлень після повторного підключення Спроби отримати пропущені повідомлення, які не були отримані під час з\'єднання, обриваються diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c1c8e296..7750541d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -443,8 +443,12 @@ 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 + Users + Commands + Supibot commands Load message history on start load_message_history_key Load message history after a reconnect From 75d0bdd966f0d1d03d82ca8d2a40df9e4c52523b Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 19:07:17 +0200 Subject: [PATCH 218/349] feat(stream): Add audio-only mode with collapsible bar and menu toggle --- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 26 + .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 756 +++++++++--------- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 61 +- .../dankchat/ui/main/QuickActionsMenu.kt | 23 + .../dankchat/ui/main/input/ChatBottomBar.kt | 2 + .../ui/main/input/ChatInputCallbacks.kt | 1 + .../dankchat/ui/main/input/ChatInputLayout.kt | 6 + .../dankchat/ui/main/stream/AudioOnlyBar.kt | 71 ++ .../dankchat/ui/main/stream/StreamView.kt | 54 +- .../ui/main/stream/StreamViewModel.kt | 19 +- .../main/res/values-b+zh+Hant+TW/strings.xml | 2 + app/src/main/res/values-be-rBY/strings.xml | 2 + app/src/main/res/values-ca/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-de-rDE/strings.xml | 2 + app/src/main/res/values-en-rAU/strings.xml | 2 + app/src/main/res/values-en-rGB/strings.xml | 2 + app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values-es-rES/strings.xml | 2 + app/src/main/res/values-fi-rFI/strings.xml | 2 + app/src/main/res/values-fr-rFR/strings.xml | 2 + app/src/main/res/values-hu-rHU/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ja-rJP/strings.xml | 2 + app/src/main/res/values-kk-rKZ/strings.xml | 2 + app/src/main/res/values-or-rIN/strings.xml | 2 + app/src/main/res/values-pl-rPL/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-pt-rPT/strings.xml | 2 + app/src/main/res/values-ru-rRU/strings.xml | 2 + app/src/main/res/values-sr/strings.xml | 2 + app/src/main/res/values-tr-rTR/strings.xml | 2 + app/src/main/res/values-uk-rUA/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 34 files changed, 661 insertions(+), 406 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/AudioOnlyBar.kt 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 index f742d1812..b70a3ead9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -35,6 +35,7 @@ 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 @@ -318,7 +319,9 @@ fun ChatScreen( @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, @@ -525,6 +528,29 @@ private fun FabActionsMenu( }, ) } + + 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 > 0) { VerticalScrollbar( 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 index 782997efb..30c938475 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -102,6 +102,7 @@ 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 @@ -117,9 +118,12 @@ fun FloatingToolbar( 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, @@ -209,432 +213,444 @@ fun FloatingToolbar( }.padding(top = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + 8.dp) } - Box(modifier = scrimModifier) { - // 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) - } + 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), + ) } - - // Mention indicators based on scroll position and tab positions - val hasLeftMention by remember(tabState.tabs) { - derivedStateOf { - val scrollPos = tabScrollState.value + Box { + // Center selected tab when selection changes + LaunchedEffect(selectedIndex, tabOffsets.value, tabWidths.value, tabViewportWidth) { 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 + 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) } } - } - 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 + + // 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 + } } } - } - - 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)) + 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 + } + } } - // 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(), + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .onSizeChanged { + val h = it.height.toFloat() + if (toolbarRowHeight == 0f || h < toolbarRowHeight) toolbarRowHeight = h + }, + verticalAlignment = Alignment.Top, ) { - 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 textColor = - when { - isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 || tab.hasUnread -> 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 = if (isSelected) FontWeight.Bold 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 - }.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, - ) - } - } - } - } + // Push action pill to end when no tabs are shown + if (endAligned && (!showTabs || tabState.tabs.isEmpty())) { + Spacer(modifier = Modifier.weight(1f)) + } - // 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) } + // 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.large, + shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.predictiveBackScale(quickSwitchBackProgress), + 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), + ) + } + }, ) { - 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) - ScrollArea( - state = quickSwitchScrollAreaState, - modifier = - Modifier - .width(IntrinsicSize.Min) - .widthIn(min = 125.dp, max = 200.dp) - .heightIn(max = maxMenuHeight), - ) { - Column( + val pillColor = MaterialTheme.colorScheme.surfaceContainer + Box { + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() - .verticalScroll(quickSwitchScrollState) - .padding(vertical = 8.dp), + .padding(horizontal = 12.dp) + .onSizeChanged { tabViewportWidth = it.width } + .clipToBounds() + .horizontalScroll(tabScrollState), ) { tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex + val textColor = + when { + isSelected -> MaterialTheme.colorScheme.primary + tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() - .clickable { - onAction(ToolbarAction.SelectTab(index)) - showQuickSwitch = false - }.padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + .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, - style = MaterialTheme.typography.bodyLarge, - color = - when { - isSelected -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface - }, + color = textColor, + style = MaterialTheme.typography.labelLarge, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, ) if (tab.mentionCount > 0) { - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(4.dp)) Badge() } } } + if (hasOverflow) { + Spacer(Modifier.width(18.dp)) + } } - if (quickSwitchScrollState.maxValue > 0) { - VerticalScrollbar( + + // Quick switch dropdown indicator (overlays end of tabs) + if (hasOverflow) { + Box( modifier = Modifier - .align(Alignment.TopEnd) - .fillMaxHeight() - .width(3.dp) - .padding(vertical = 2.dp), + .align(Alignment.CenterEnd) + .clickable { + showOverflowMenu = false + showQuickSwitch = !showQuickSwitch + }.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, ) { - Thumb( - Modifier.background( - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), - RoundedCornerShape(100), - ), + 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) + 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 + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onAction(ToolbarAction.SelectTab(index)) + showQuickSwitch = false + }.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = tab.displayName, + style = MaterialTheme.typography.bodyLarge, + color = + when { + isSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(8.dp)) + Badge() + } + } + } + } + if (quickSwitchScrollState.maxValue > 0) { + 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)) + // 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() + 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)) } - LaunchedEffect(Unit) { - snapshotFlow { addChannelTooltipState.isVisible } - .dropWhile { !it } // skip initial false - .first { !it } // wait for dismiss (any cause) - onAddChannelTooltipDismiss() + val addChannelIcon: @Composable () -> Unit = { + IconButton(onClick = { onAction(ToolbarAction.AddChannel) }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_channel), + ) + } } - 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)) + 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, - ) { + }, + ) { + Text(stringResource(R.string.tour_add_more_channels_hint)) + } + }, + state = addChannelTooltipState, + hasAction = true, + ) { + addChannelIcon() + } + } else { addChannelIcon() } - } else { - addChannelIcon() - } - if (isLoggedIn) { - IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { + if (isLoggedIn) { + IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { + Icon( + imageVector = Icons.Default.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 + }) { Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.mentions_title), - tint = - if (totalMentionCount > 0) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - }, + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), ) } } - IconButton(onClick = { - showQuickSwitch = false - overflowInitialMenu = AppBarMenu.Main - showOverflowMenu = !showOverflowMenu - }) { - 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, - ) + 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, + ) + } } } } 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 index 76b015898..d94bae100 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -20,6 +20,7 @@ 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 @@ -188,6 +189,7 @@ fun MainScreen( 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 @@ -406,6 +408,7 @@ fun MainScreen( else -> activeChannel?.let { streamViewModel.toggleStream(it) } } }, + onAudioOnly = { streamViewModel.toggleAudioOnly() }, onModActions = dialogViewModel::showModActions, onInputActionsChange = mainScreenViewModel::updateInputActions, onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(it) } }, @@ -423,6 +426,7 @@ fun MainScreen( isFullscreen = isFullscreen, isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), isStreamActive = currentStream != null, + isAudioOnly = isAudioOnly, hasStreamData = hasStreamData, isSheetOpen = isSheetOpen, inputActions = @@ -561,9 +565,12 @@ fun MainScreen( isFullscreen = isFullscreen, isLoggedIn = isLoggedIn, currentStream = currentStream, + isAudioOnly = isAudioOnly, streamHeightDp = streamState.heightDp, totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, onAction = handleToolbarAction, + onAudioOnly = { streamViewModel.toggleAudioOnly() }, + onStreamClose = { streamViewModel.closeStream() }, endAligned = endAligned, showTabs = showTabs, addChannelTooltipState = if (featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) featureTourViewModel.addChannelTooltipState else null, @@ -660,7 +667,9 @@ fun MainScreen( val fabMenuCallbacks = FabMenuCallbacks( onAction = fabActionHandler, + onAudioOnly = { streamViewModel.toggleAudioOnly() }, isStreamActive = currentStream != null, + isAudioOnly = isAudioOnly, hasStreamData = hasStreamData, isFullscreen = isFullscreen, isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), @@ -727,11 +736,14 @@ fun MainScreen( 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, @@ -758,7 +770,9 @@ fun MainScreen( } else { NormalStackedLayout( currentStream = currentStream, + isAudioOnly = isAudioOnly, onStreamClose = onStreamClose, + onAudioOnly = onAudioOnly, hasWebViewBeenAttached = streamViewModel.hasWebViewBeenAttached, streamState = streamState, scaffoldContent = scaffoldContent, @@ -795,7 +809,9 @@ fun MainScreen( @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, @@ -832,17 +848,19 @@ private fun BoxScope.WideSplitLayout( .onSizeChanged { containerWidthPx = it.width }, ) { Row(modifier = Modifier.fillMaxSize()) { - // Left pane: Stream + // Left pane: Stream (hidden but composed in audio-only mode to keep audio playing) Box( modifier = - Modifier - .weight(splitFraction) - .fillMaxSize(), + when { + isAudioOnly -> Modifier.width(0.dp) + else -> Modifier.weight(splitFraction).fillMaxSize() + }, ) { StreamView( channel = currentStream ?: return, fillPane = true, onClose = onStreamClose, + onAudioOnly = onAudioOnly, modifier = Modifier.fillMaxSize(), ) } @@ -851,7 +869,7 @@ private fun BoxScope.WideSplitLayout( Box( modifier = Modifier - .weight(1f - splitFraction) + .weight(if (isAudioOnly) 1f else 1f - splitFraction) .fillMaxSize(), ) { val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -926,24 +944,28 @@ private fun BoxScope.WideSplitLayout( } } - 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() }, - ) + 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, onStreamClose: () -> Unit, + onAudioOnly: () -> Unit, hasWebViewBeenAttached: Boolean, streamState: StreamToolbarState, scaffoldContent: @Composable (PaddingValues, Dp) -> Unit, @@ -998,7 +1020,7 @@ private fun BoxScope.NormalStackedLayout( // Stream View layer — kept in composition when hidden so the WebView // stays attached and audio/video continues playing without re-buffering. currentStream?.let { channel -> - val showStream = isInPipMode || (!isKeyboardVisible && !isEmoteMenuOpen) || isLandscape + val showStream = !isAudioOnly && (isInPipMode || (!isKeyboardVisible && !isEmoteMenuOpen) || isLandscape) var streamComposed by remember { mutableStateOf(hasWebViewBeenAttached) } LaunchedEffect(showStream) { if (showStream) { @@ -1011,6 +1033,7 @@ private fun BoxScope.NormalStackedLayout( channel = channel, isInPipMode = isInPipMode, onClose = onStreamClose, + onAudioOnly = onAudioOnly, modifier = when { isInPipMode -> { @@ -1041,8 +1064,8 @@ private fun BoxScope.NormalStackedLayout( } } - // Status bar scrim when stream is active - if (currentStream != null && !isFullscreen && !isInPipMode) { + // Status bar scrim when stream video is active (not audio-only) + if (currentStream != null && !isAudioOnly && !isFullscreen && !isInPipMode) { StatusBarScrim( colorAlpha = 1f, modifier = 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 index 6d2dbd707..488edff9b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -14,6 +14,7 @@ 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 @@ -61,11 +62,13 @@ fun QuickActionsMenu( 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, ) { @@ -101,6 +104,26 @@ fun QuickActionsMenu( } } + 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.Default.Videocam else Icons.Default.Headphones, + contentDescription = null, + ) + }, + ) + } + HorizontalDivider() val configureItem: @Composable () -> Unit = { 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 index cf7d00e50..1e9be4e5d 100644 --- 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 @@ -48,6 +48,7 @@ fun ChatBottomBar( isFullscreen: Boolean, isModerator: Boolean, isStreamActive: Boolean, + isAudioOnly: Boolean, hasStreamData: Boolean, isSheetOpen: Boolean, inputActions: ImmutableList, @@ -82,6 +83,7 @@ fun ChatBottomBar( isFullscreen = isFullscreen, isModerator = isModerator, isStreamActive = isStreamActive, + isAudioOnly = isAudioOnly, hasStreamData = hasStreamData, inputActions = inputActions, debugMode = debugMode, 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 index 7b9b5ffeb..f7b6e3f11 100644 --- 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 @@ -11,6 +11,7 @@ data class ChatInputCallbacks( val onToggleFullscreen: () -> Unit, val onToggleInput: () -> Unit, val onToggleStream: () -> Unit, + val onAudioOnly: () -> Unit, val onModActions: () -> Unit, val onInputActionsChange: (ImmutableList) -> Unit, val onSearchClick: () -> 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 index 8f7b68e3d..9f13fc3c1 100644 --- 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 @@ -122,6 +122,7 @@ fun ChatInputLayout( isFullscreen: Boolean, isModerator: Boolean, isStreamActive: Boolean, + isAudioOnly: Boolean, hasStreamData: Boolean, inputActions: ImmutableList, modifier: Modifier = Modifier, @@ -395,6 +396,7 @@ fun ChatInputLayout( enabled = enabled, hasLastMessage = hasLastMessage, isStreamActive = isStreamActive, + isAudioOnly = isAudioOnly, hasStreamData = hasStreamData, isFullscreen = isFullscreen, isModerator = isModerator, @@ -411,6 +413,10 @@ fun ChatInputLayout( } onOverflowExpandedChange(false) }, + onAudioOnly = { + callbacks.onAudioOnly() + onOverflowExpandedChange(false) + }, onConfigureClick = { onOverflowExpandedChange(false) showConfigSheet = true 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..0be6d10d5 --- /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.filled.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.Default.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 index 4ff63b0a6..643c7d84a 100644 --- 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 @@ -7,6 +7,7 @@ import android.webkit.WebViewClient 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 @@ -16,6 +17,7 @@ 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 @@ -40,10 +42,11 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun StreamView( channel: UserName, + onClose: () -> Unit, + onAudioOnly: () -> Unit, modifier: Modifier = Modifier, isInPipMode: Boolean = false, fillPane: Boolean = false, - onClose: () -> Unit, ) { val streamViewModel: StreamViewModel = koinViewModel() // Track whether the WebView has been attached to a window before. @@ -137,30 +140,55 @@ fun StreamView( } if (!isInPipMode) { - Box( - contentAlignment = Alignment.Center, + Row( modifier = Modifier .align(Alignment.TopEnd) .statusBarsPadding() - .padding(8.dp) - .size(28.dp) - .background( - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f), - shape = CircleShape, - ).clickable(onClick = onClose), + .padding(8.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement + .spacedBy(6.dp), ) { - Icon( - imageVector = Icons.Default.Close, + 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), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(18.dp), + 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() { 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 index d82b541b5..67dd6eccc 100644 --- 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 @@ -37,20 +37,24 @@ class StreamViewModel( }.distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + private val _isAudioOnly = MutableStateFlow(false) + val streamState: StateFlow = combine( _currentStreamedChannel, hasStreamData, - ) { currentStream, hasData -> - StreamState(currentStream = currentStream, hasStreamData = hasData) + _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, pipEnabled -> - currentStream != null && pipEnabled + ) { currentStream, audioOnly, pipEnabled -> + currentStream != null && !audioOnly && pipEnabled }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { @@ -108,10 +112,16 @@ class StreamViewModel( 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() { @@ -127,4 +137,5 @@ class StreamViewModel( data class StreamState( val currentStream: UserName? = null, val hasStreamData: Boolean = false, + val isAudioOnly: Boolean = false, ) 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 a83d387c0..48d2b25fd 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 @@ -258,6 +258,8 @@ 開啟/關閉實況 顯示實況 隱藏實況 + 僅音訊 + 退出僅音訊模式 全螢幕 退出全螢幕 隱藏輸入框 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 1cfb3390e..bb5e38009 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -632,6 +632,8 @@ Паказаць трансляцыю Схаваць трансляцыю + Толькі аўдыё + Выйсці з рэжыму аўдыё На ўвесь экран Выйсці з поўнаэкраннага рэжыму Схаваць увод diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 8b5b1be83..8820ea3a1 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -566,6 +566,8 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Mostra l\'emissió Amaga l\'emissió + Només àudio + Surt del mode àudio Pantalla completa Surt de la pantalla completa Amaga l\'entrada diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f051f8720..9c8c25741 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -633,6 +633,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Zobrazit stream Skrýt stream + Pouze zvuk + Ukončit režim zvuku Celá obrazovka Ukončit celou obrazovku Skrýt vstup diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index e48bf17d0..f547eb5d5 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -633,6 +633,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Stream anzeigen Stream ausblenden + Nur Audio + Audio-Modus beenden Vollbild Vollbild beenden Eingabe ausblenden diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 97b3c44da..599aff8ec 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -438,6 +438,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/No recent emotes Show stream Hide stream + Audio only + Exit audio only Fullscreen Exit fullscreen Hide input diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 40e828d05..1876abdac 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -439,6 +439,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/No recent emotes Show stream Hide stream + Audio only + Exit audio only Fullscreen Exit fullscreen Hide input diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index c38b6033c..c70ac3324 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -627,6 +627,8 @@ Show stream Hide stream + Audio only + Exit audio only Fullscreen Exit fullscreen Hide input diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 362a0a79e..bd61186f0 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -642,6 +642,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/ Mostrar stream Ocultar stream + Solo audio + Salir de solo audio Pantalla completa Salir de pantalla completa Ocultar entrada diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 5da12ceba..b42f5446e 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -624,6 +624,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Näytä lähetys Piilota lähetys + Vain ääni + Poistu äänitilasta Koko näyttö Poistu koko näytöstä Piilota syöttö diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 2ea7d9b4d..9a7f85224 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -626,6 +626,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Afficher le stream Masquer le stream + Audio uniquement + Quitter le mode audio Plein écran Quitter le plein écran Masquer la saisie diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 84648acbf..3fb6b0013 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -611,6 +611,8 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4f3c5252e..65b0e2b38 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -609,6 +609,8 @@ Mostra stream Nascondi stream + Solo audio + Esci da solo audio Schermo intero Esci dallo schermo intero Nascondi input diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 2d70c5e2b..d8d663b12 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -592,6 +592,8 @@ 配信を表示 配信を非表示 + 音声のみ + 音声モードを終了 全画面 全画面を終了 入力欄を非表示 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 1bf2cebae..3b8a77ebe 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -256,6 +256,8 @@ Ағынды ажырату Ағынды көрсету Ағынды жасыру + Тек аудио + Аудио режимінен шығу Толық экран Толық экраннан шығу Енгізуді жасыру diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index d355165fa..e7b9008f9 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -256,6 +256,8 @@ ଷ୍ଟ୍ରିମ୍ ଟୋଗଲ୍ କରନ୍ତୁ | ଷ୍ଟ୍ରିମ୍ ଦେଖାନ୍ତୁ ଷ୍ଟ୍ରିମ୍ ଲୁଚାନ୍ତୁ + କେବଳ ଅଡିଓ + କେବଳ ଅଡିଓ ମୋଡରୁ ବାହାରନ୍ତୁ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ବାହାରନ୍ତୁ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 090c0666a..ac8817196 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -651,6 +651,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pokaż transmisję Ukryj transmisję + Tylko dźwięk + Wyłącz tryb audio Pełny ekran Wyjdź z pełnego ekranu Ukryj pole wpisywania diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b5cffae1a..7bbce4ae0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -621,6 +621,8 @@ Mostrar stream Ocultar stream + Apenas áudio + Sair do modo áudio Tela cheia Sair da tela cheia Ocultar entrada diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 6229f7d30..9cd4f7979 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -611,6 +611,8 @@ Mostrar transmissão Ocultar transmissão + Apenas áudio + Sair do modo áudio Ecrã inteiro Sair do ecrã inteiro Ocultar entrada diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index a47f6812e..83b843484 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -637,6 +637,8 @@ Показать трансляцию Скрыть трансляцию + Только аудио + Выйти из режима аудио На весь экран Выйти из полноэкранного режима Скрыть ввод diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 416ba07fe..13339eed7 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -631,6 +631,8 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/ Прикажи стрим Сакриј стрим + Само звук + Изађи из режима звука Цео екран Изађи из целог екрана Сакриј унос diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index eccc73b13..f03a1ff56 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -632,6 +632,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Yayını göster Yayını gizle + Yalnızca ses + Ses modundan çık Tam ekran Tam ekrandan çık Girişi gizle diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index da4cc320a..cd96ac410 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -634,6 +634,8 @@ Показати трансляцію Сховати трансляцію + Лише аудіо + Вийти з режиму аудіо На весь екран Вийти з повноекранного режиму Сховати введення diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7750541d5..77f6f6872 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -286,6 +286,8 @@ Toggle stream Show stream Hide stream + Audio only + Exit audio only Fullscreen Exit fullscreen Hide input From f2bb78f682cc08fe2653aff64b95d05e15d7aa88 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 19:12:55 +0200 Subject: [PATCH 219/349] fix(stream): Close stream when its channel is removed --- .../com/flxrs/dankchat/ui/main/stream/StreamViewModel.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 index 67dd6eccc..fc692134c 100644 --- 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 @@ -62,6 +62,10 @@ class StreamViewModel( chatChannelProvider.channels.collect { channels -> if (channels != null) { streamDataRepository.fetchStreamData(channels) + val current = _currentStreamedChannel.value + if (current != null && current !in channels) { + closeStream() + } } } } From a289162876829c8f35779d0827ee860db0d6599d Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 19:15:53 +0200 Subject: [PATCH 220/349] chore: Bump version to 4.0.0 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06cd9558f..4d801c139 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 31111 - versionName = "3.11.11" + versionCode = 40000 + versionName = "4.0.0" } androidResources { generateLocaleConfig = true } From 34e904bb94131aaa71611fda6430f68ef76e6a36 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 19:48:16 +0200 Subject: [PATCH 221/349] refactor(emotes): Remove unused DankChat API emote set fallback --- .../dankchat/data/api/dankchat/DankChatApi.kt | 5 -- .../data/api/dankchat/DankChatApiClient.kt | 8 -- .../data/api/dankchat/dto/DankChatEmoteDto.kt | 14 ---- .../api/dankchat/dto/DankChatEmoteSetDto.kt | 17 ---- .../dankchat/data/repo/data/DataRepository.kt | 7 -- .../data/repo/emote/EmoteRepository.kt | 80 ------------------- 6 files changed, 131 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteDto.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteSetDto.kt 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 944450468..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,14 +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) - } - suspend fun getDankChatBadges() = ktorClient.get("badges") } 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 aaa2ed785..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,7 +1,6 @@ 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 @@ -12,13 +11,6 @@ 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() - } - suspend fun getDankChatBadges(): Result> = runCatching { dankChatApi .getDankChatBadges() 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 890d4120e..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?, -) 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 49e998d59..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?, -) 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 62b65414f..6b1e85b15 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 @@ -161,13 +161,6 @@ class DataRepository( .loadUserEmotes(userId, onFirstPageLoaded) .getOrEmitFailure { DataLoadingStep.TwitchEmotes } - suspend fun loadUserStateEmotes( - globalEmoteSetIds: List, - followerEmoteSetIds: Map>, - ) { - emoteRepository.loadUserStateEmotes(globalEmoteSetIds, followerEmoteSetIds) - } - suspend fun sendShutdownCommand() { serviceEventChannel.send(ServiceEvent.Shutdown) } 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 41085c006..15b834be6 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 @@ -13,9 +13,7 @@ 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 @@ -65,7 +63,6 @@ import java.util.concurrent.CopyOnWriteArrayList @Single class EmoteRepository( - private val dankChatApiClient: DankChatApiClient, private val helixApiClient: HelixApiClient, private val chatSettingsDataStore: ChatSettingsDataStore, private val channelRepository: ChannelRepository, @@ -485,55 +482,6 @@ class EmoteRepository( Log.d(TAG, "Helix getUserEmotes: $totalCount total, ${seenIds.size} unique, ${allEmotes.size} resolved") } - 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() - }.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 - }?.let { EmoteType.ChannelTwitchFollowerEmote(it.key) } - ?: emoteSet.channelName.twitchEmoteType - } - } - emoteSet.emotes.mapToGenericEmotes(type) - } - - val globalTwitchEmotes = twitchEmotes.filter { it.emoteType is EmoteType.GlobalTwitchEmote || it.emoteType is EmoteType.ChannelTwitchEmote } - val followerEmotes = twitchEmotes.filter { it.emoteType is EmoteType.ChannelTwitchFollowerEmote } - - globalEmoteState.update { it.copy(twitchEmotes = globalTwitchEmotes) } - - channelEmoteStates.forEach { (channel, flow) -> - flow.update { - it.copy( - twitchEmotes = followerEmotes.filterNot { emote -> emote.emoteType is EmoteType.ChannelTwitchFollowerEmote && emote.emoteType.channel != channel }, - ) - } - } - } - suspend fun setFFZEmotes( channel: UserName, ffzResult: FFZChannelDto, @@ -749,16 +697,6 @@ class EmoteRepository( } } - private val UserName?.twitchEmoteType: EmoteType - get() = - when { - this == null || isGlobalTwitchChannel -> EmoteType.GlobalTwitchEmote - else -> EmoteType.ChannelTwitchEmote(this) - } - - private val UserName.isGlobalTwitchChannel: Boolean - get() = value.equals("qa_TW_Partner", ignoreCase = true) || value.equals("Twitch", ignoreCase = true) - private fun UserEmoteDto.toGenericEmote(type: EmoteType): GenericEmote { val code = when (type) { @@ -775,23 +713,6 @@ class EmoteRepository( ) } - private fun List?.mapToGenericEmotes(type: EmoteType): List = this - ?.map { (name, id) -> - val code = - when (type) { - is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name - else -> name - } - GenericEmote( - code = code, - url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), - lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), - id = id, - scale = 1, - emoteType = type, - ) - }.orEmpty() - @VisibleForTesting fun adjustOverlayEmotes( message: String, @@ -1009,7 +930,6 @@ class EmoteRepository( val ESCAPE_TAG_REGEX = "(? Date: Tue, 31 Mar 2026 20:56:28 +0200 Subject: [PATCH 222/349] fix: Use thread-safe collections, fix coroutine scope in NotificationService, prevent badge duplication --- .../data/notification/NotificationService.kt | 78 +++++++++---------- .../dankchat/data/repo/RepliesRepository.kt | 6 +- .../data/repo/chat/RecentMessagesHandler.kt | 3 +- .../data/repo/emote/EmoteRepository.kt | 1 + 4 files changed, 44 insertions(+), 44 deletions(-) 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 4941df0bb..6144af9cf 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 @@ -20,15 +20,17 @@ import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataSto import com.flxrs.dankchat.ui.main.MainActivity import com.flxrs.dankchat.utils.AppLifecycleListener import com.flxrs.dankchat.utils.AppLifecycleListener.AppLifecycle +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.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import org.koin.android.ext.android.inject +import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.atomics.AtomicInt import kotlin.coroutines.CoroutineContext @@ -38,8 +40,8 @@ class NotificationService : private val binder = LocalBinder() private val manager: NotificationManager by lazy { getSystemService(NOTIFICATION_SERVICE) as NotificationManager } - private val notifications = mutableMapOf>() - private val notifiedMessageIds = LinkedHashSet() + private val notifications = ConcurrentHashMap>() + private val notifiedMessageIds: MutableSet = ConcurrentSet() private val chatNotificationRepository: ChatNotificationRepository by inject() private val chatChannelProvider: ChatChannelProvider by inject() @@ -49,8 +51,8 @@ class NotificationService : private val pendingIntentFlag: Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - override val coroutineContext: CoroutineContext - get() = Dispatchers.IO + Job() + private val job = SupervisorJob() + override val coroutineContext: CoroutineContext = Dispatchers.IO + job inner class LocalBinder( val service: NotificationService = this@NotificationService, @@ -83,43 +85,41 @@ class NotificationService : launch { appLifecycleListener.appState - .map { it == AppLifecycle.Foreground } - .distinctUntilChanged() - .collect { isForeground -> - if (isForeground) { - notifiedMessageIds.clear() - val activeChannel = chatChannelProvider.activeChannel.value - if (activeChannel != null) { - clearNotificationsForChannel(activeChannel) + .flatMapLatest { state -> + when (state) { + AppLifecycle.Foreground -> { + notifiedMessageIds.clear() + val activeChannel = chatChannelProvider.activeChannel.value + if (activeChannel != null) { + clearNotificationsForChannel(activeChannel) + } + emptyFlow() } - } - } - } - - launch { - combine( - chatNotificationRepository.messageUpdates, - notificationsSettingsDataStore.showNotifications, - appLifecycleListener.appState, - ) { items, enabled, lifecycle -> - Triple(items, enabled, lifecycle) - }.collect { (items, enabled, lifecycle) -> - if (!enabled || lifecycle != AppLifecycle.Background) { - return@collect - } - items.forEach { (message) -> - if (!notifiedMessageIds.add(message.id)) { - return@forEach + AppLifecycle.Background -> { + combine( + chatNotificationRepository.messageUpdates, + notificationsSettingsDataStore.showNotifications, + ) { items, enabled -> items to enabled } + } + } + }.collect { (items, enabled) -> + if (!enabled) { + return@collect } - if (notifiedMessageIds.size > MAX_NOTIFIED_IDS) { - val iterator = notifiedMessageIds.iterator() - iterator.next() - iterator.remove() + + 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() } - message.toNotificationData()?.createMentionNotification() } - } } } 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 b1a169e8d..a6fdd9423 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 @@ -86,10 +86,8 @@ class RepliesRepository( existing.copy(replies = existing.replies + strippedMessage, participated = existing.updateParticipated(strippedMessage)) } } - when { - !threads.containsKey(rootId) -> threads[rootId] = MutableStateFlow(thread) - else -> threads.getValue(rootId).update { thread } - } + val existing = threads.putIfAbsent(rootId, MutableStateFlow(thread)) + existing?.update { thread } val parentMessageId = message.tags[PARENT_MESSAGE_ID_TAG] val parentInThread = 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 index b2eb4875b..4e2133acc 100644 --- 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 @@ -22,6 +22,7 @@ import com.flxrs.dankchat.data.twitch.message.hasMention import com.flxrs.dankchat.data.twitch.message.toChatItem import com.flxrs.dankchat.utils.extensions.addAndLimit import com.flxrs.dankchat.utils.extensions.replaceOrAddHistoryModerationMessage +import io.ktor.util.collections.ConcurrentSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext @@ -35,7 +36,7 @@ class RecentMessagesHandler( private val chatMessageRepository: ChatMessageRepository, private val usersRepository: UsersRepository, ) { - private val loadedChannels = mutableSetOf() + private val loadedChannels = ConcurrentSet() data class Result( val mentionItems: List, 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 15b834be6..a4cf79a5a 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 @@ -386,6 +386,7 @@ class EmoteRepository( } fun setDankChatBadges(dto: List) { + dankChatBadges.clear() dankChatBadges.addAll(dto) } From f0e7f347e5e79b8f99a9da96277da2f7eb9c5298 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 20:58:26 +0200 Subject: [PATCH 223/349] chore: Bump version to 4.0.1 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d801c139..0f620666a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40000 - versionName = "4.0.0" + versionCode = 40001 + versionName = "4.0.1" } androidResources { generateLocaleConfig = true } From fe8f1833b737c93c6d22bb5d180bed02e45975f9 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 21:41:06 +0200 Subject: [PATCH 224/349] fix(ui): Unify input visibility state, reduce suggestion padding, remember emote menu tab --- .../dankchat/ui/chat/emotemenu/EmoteMenu.kt | 7 ++- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 52 +++++++++---------- .../ui/main/MainScreenPagerContent.kt | 6 +-- .../dankchat/ui/main/MainScreenUiState.kt | 2 - .../dankchat/ui/main/MainScreenViewModel.kt | 24 +++++---- .../ui/main/input/SuggestionDropdown.kt | 2 +- .../ui/main/sheet/EmoteMenuViewModel.kt | 9 ++++ 7 files changed, 57 insertions(+), 45 deletions(-) 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 index 449582164..744c1380c 100644 --- 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 @@ -58,12 +58,17 @@ fun EmoteMenu( viewModel: EmoteMenuViewModel = koinViewModel(), ) { val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() + val selectedTabIndex by viewModel.selectedTabIndex.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val pagerState = rememberPagerState( - initialPage = 0, + initialPage = selectedTabIndex, pageCount = { tabItems.size }, ) + + LaunchedEffect(pagerState.currentPage) { + viewModel.selectTab(pagerState.currentPage) + } val subsGridState = rememberLazyGridState() val subsFirstHeader = tabItems 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 index d94bae100..9797c4e53 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -279,7 +279,7 @@ fun MainScreen( ) val isFullscreen = mainState.isFullscreen - val effectiveShowInput = mainState.effectiveShowInput + val showInput = mainState.showInput val swipeNavigation = mainState.swipeNavigation val effectiveShowAppBar = mainState.effectiveShowAppBar @@ -307,8 +307,8 @@ fun MainScreen( var inputHeightPx by remember { mutableIntStateOf(0) } var helperTextHeightPx by remember { mutableIntStateOf(0) } var inputOverflowExpanded by remember { mutableStateOf(false) } - if (!effectiveShowInput) inputHeightPx = 0 - if (effectiveShowInput || inputState.helperText.isEmpty) helperTextHeightPx = 0 + if (!showInput) inputHeightPx = 0 + if (showInput || inputState.helperText.isEmpty) helperTextHeightPx = 0 val inputHeightDp = with(density) { inputHeightPx.toDp() } val helperTextHeightDp = with(density) { helperTextHeightPx.toDp() } @@ -377,7 +377,7 @@ fun MainScreen( // Shared bottom bar content val bottomBar: @Composable () -> Unit = { ChatBottomBar( - showInput = effectiveShowInput && !isHistorySheet, + showInput = showInput && !isHistorySheet, textFieldState = chatInputViewModel.textFieldState, uiState = inputState, callbacks = @@ -607,9 +607,7 @@ fun MainScreen( onShowEmoteInfo = dialogViewModel::showEmoteInfo, onOpenReplies = sheetNavigationViewModel::openReplies, onRecover = { - if (mainScreenViewModel.uiState.value.isFullscreen) mainScreenViewModel.toggleFullscreen() - if (!mainScreenViewModel.uiState.value.showInput) mainScreenViewModel.toggleInput() - mainScreenViewModel.resetGestureState() + mainScreenViewModel.recoverInputAndFullscreen() }, onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, onTourAdvance = featureTourViewModel::advance, @@ -652,10 +650,7 @@ fun MainScreen( } InputAction.HideInput -> { - if (!mainScreenViewModel.uiState.value.showInput) { - mainScreenViewModel.toggleInput() - } - mainScreenViewModel.resetGestureState() + mainScreenViewModel.hideInput() } InputAction.Debug -> { @@ -686,7 +681,7 @@ fun MainScreen( composePagerState = composePagerState, pagerState = pagerState, isLoggedIn = isLoggedIn, - effectiveShowInput = effectiveShowInput, + showInput = showInput, isFullscreen = isFullscreen, swipeNavigation = swipeNavigation, isSheetOpen = isSheetOpen, @@ -710,7 +705,7 @@ fun MainScreen( val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> val effectiveBottomPadding = when { - !effectiveShowInput -> bottomPadding + max(navBarHeightDp, effectiveRoundedCorner) + !showInput -> bottomPadding + max(navBarHeightDp, effectiveRoundedCorner) else -> bottomPadding } FullScreenSheetOverlay( @@ -757,13 +752,13 @@ fun MainScreen( isKeyboardVisible = isKeyboardVisible, isEmoteMenuOpen = inputState.isEmoteMenuOpen, isSheetOpen = isSheetOpen, - effectiveShowInput = effectiveShowInput, + showInput = showInput, inputOverflowExpanded = inputOverflowExpanded, forceOverflowOpen = featureTourState.forceOverflowOpen, swipeDownThresholdPx = swipeDownThresholdPx, suggestions = inputState.suggestions, onSuggestionClick = chatInputViewModel::applySuggestion, - onHideInput = { mainScreenViewModel.setGestureInputHidden(true) }, + onHideInput = { mainScreenViewModel.hideInput() }, onDismissOverflow = { inputOverflowExpanded = false }, modifier = modifier, ) @@ -791,13 +786,13 @@ fun MainScreen( isInPipMode = isInPipMode, isWideWindow = isWideWindow, isLandscape = isLandscape, - effectiveShowInput = effectiveShowInput, + showInput = showInput, inputOverflowExpanded = inputOverflowExpanded, forceOverflowOpen = featureTourState.forceOverflowOpen, swipeDownThresholdPx = swipeDownThresholdPx, suggestions = inputState.suggestions, onSuggestionClick = chatInputViewModel::applySuggestion, - onHideInput = { mainScreenViewModel.setGestureInputHidden(true) }, + onHideInput = { mainScreenViewModel.hideInput() }, onDismissOverflow = { inputOverflowExpanded = false }, modifier = modifier, ) @@ -825,7 +820,7 @@ private fun BoxScope.WideSplitLayout( isKeyboardVisible: Boolean, isEmoteMenuOpen: Boolean, isSheetOpen: Boolean, - effectiveShowInput: Boolean, + showInput: Boolean, inputOverflowExpanded: Boolean, forceOverflowOpen: Boolean, swipeDownThresholdPx: Float, @@ -919,7 +914,7 @@ private fun BoxScope.WideSplitLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = effectiveShowInput, + enabled = showInput, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), @@ -929,7 +924,7 @@ private fun BoxScope.WideSplitLayout( emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - if (effectiveShowInput && isKeyboardVisible) { + if (showInput && isKeyboardVisible) { SuggestionDropdown( suggestions = suggestions, onSuggestionClick = onSuggestionClick, @@ -984,7 +979,7 @@ private fun BoxScope.NormalStackedLayout( isInPipMode: Boolean, isWideWindow: Boolean, isLandscape: Boolean, - effectiveShowInput: Boolean, + showInput: Boolean, inputOverflowExpanded: Boolean, forceOverflowOpen: Boolean, swipeDownThresholdPx: Float, @@ -1106,7 +1101,7 @@ private fun BoxScope.NormalStackedLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = effectiveShowInput, + enabled = showInput, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), @@ -1117,7 +1112,7 @@ private fun BoxScope.NormalStackedLayout( if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) - if (!isInPipMode && effectiveShowInput && isKeyboardVisible) { + if (!isInPipMode && showInput && isKeyboardVisible) { SuggestionDropdown( suggestions = suggestions, onSuggestionClick = onSuggestionClick, @@ -1202,16 +1197,19 @@ private fun MainScreenTourEffects( } } - // Sync tour's gestureInputHidden with MainScreenViewModel + // Sync tour's input hidden state with MainScreenViewModel LaunchedEffect(featureTourState.gestureInputHidden, featureTourState.isTourActive) { if (featureTourState.isTourActive) { - mainScreenViewModel.setGestureInputHidden(featureTourState.gestureInputHidden) + when { + featureTourState.gestureInputHidden -> mainScreenViewModel.hideInput() + else -> mainScreenViewModel.recoverInputAndFullscreen() + } } } // Auto-advance tour when input is hidden during the SwipeGesture step - LaunchedEffect(mainState.gestureInputHidden, featureTourState.currentTourStep) { - if (mainState.gestureInputHidden && featureTourState.currentTourStep == TourStep.SwipeGesture) { + LaunchedEffect(mainState.showInput, featureTourState.currentTourStep) { + if (!mainState.showInput && featureTourState.currentTourStep == TourStep.SwipeGesture) { featureTourViewModel.advance() } } 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 index 52207d66f..d45c68d46 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -58,7 +58,7 @@ internal fun MainScreenPagerContent( composePagerState: PagerState, pagerState: ChannelPagerUiState, isLoggedIn: Boolean, - effectiveShowInput: Boolean, + showInput: Boolean, isFullscreen: Boolean, swipeNavigation: Boolean, isSheetOpen: Boolean, @@ -151,7 +151,7 @@ internal fun MainScreenPagerContent( onReplyClick = { replyMessageId, replyName -> callbacks.onOpenReplies(replyMessageId, replyName) }, - showInput = effectiveShowInput, + showInput = showInput, isFullscreen = isFullscreen, showFabs = !isSheetOpen, onRecover = callbacks.onRecover, @@ -162,7 +162,7 @@ internal fun MainScreenPagerContent( bottom = paddingValues.calculateBottomPadding() + when { - effectiveShowInput -> { + showInput -> { inputHeightDp } 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 index 680f13088..be230ec18 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt @@ -14,9 +14,7 @@ data class MainScreenUiState( val isRepeatedSendEnabled: Boolean = false, val debugMode: Boolean = false, val swipeNavigation: Boolean = true, - val gestureInputHidden: Boolean = false, val gestureToolbarHidden: Boolean = false, ) { - val effectiveShowInput: Boolean get() = showInput && !gestureInputHidden 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 index a625dd071..a8cae8b93 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -40,7 +40,6 @@ class MainScreenViewModel( channelDataCoordinator.globalLoadingState private val _isFullscreen = MutableStateFlow(false) - private val _gestureInputHidden = MutableStateFlow(false) private val _gestureToolbarHidden = MutableStateFlow(false) val uiState: StateFlow = @@ -48,9 +47,8 @@ class MainScreenViewModel( appearanceSettingsDataStore.settings, developerSettingsDataStore.settings, _isFullscreen, - _gestureInputHidden, _gestureToolbarHidden, - ) { appearance, developerSettings, isFullscreen, gestureInputHidden, gestureToolbarHidden -> + ) { appearance, developerSettings, isFullscreen, gestureToolbarHidden -> MainScreenUiState( isFullscreen = isFullscreen, showInput = appearance.showInput, @@ -59,7 +57,6 @@ class MainScreenViewModel( isRepeatedSendEnabled = developerSettings.repeatedSending, debugMode = developerSettings.debugMode, swipeNavigation = appearance.swipeNavigation, - gestureInputHidden = gestureInputHidden, gestureToolbarHidden = gestureToolbarHidden, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainScreenUiState()) @@ -98,17 +95,14 @@ class MainScreenViewModel( private val _keyboardHeightPx = MutableStateFlow(0) val keyboardHeightPx: StateFlow = _keyboardHeightPx.asStateFlow() - fun setGestureInputHidden(hidden: Boolean) { - _gestureInputHidden.value = hidden - } - fun setGestureToolbarHidden(hidden: Boolean) { _gestureToolbarHidden.value = hidden } - fun resetGestureState() { - _gestureInputHidden.value = false - _gestureToolbarHidden.value = false + fun hideInput() { + viewModelScope.launch { + appearanceSettingsDataStore.update { it.copy(showInput = false) } + } } init { @@ -149,6 +143,14 @@ class MainScreenViewModel( } } + 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) } 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 index d766455b5..14e40f7b6 100644 --- 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 @@ -117,7 +117,7 @@ private fun SuggestionItem( modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { // Icon/Image based on suggestion type 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 index dfdf4b117..95b9cc403 100644 --- 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 @@ -17,8 +17,10 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers 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 @@ -31,6 +33,13 @@ class EmoteMenuViewModel( chatChannelProvider: ChatChannelProvider, emoteUsageRepository: EmoteUsageRepository, ) : 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 = From 32b585871cc2ad8a2a6e0667787312642106f29b Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 22:08:54 +0200 Subject: [PATCH 225/349] fix(theme): Override all surface container variants in true dark theme --- .../flxrs/dankchat/ui/theme/DankChatTheme.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 7fff126bb..bd2bb80ab 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -19,9 +19,19 @@ import androidx.compose.ui.platform.LocalInspectionMode import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import org.koin.compose.koinInject +// True dark surface tones based on achromatic M3 neutral palette, compressed toward black. +// Standard dark tones: 0, 4, 4, 6, 9, 12, 15, 18 +// True dark tones: 0, 0, 0, 2, 4, 6, 8, 10 private val TrueDarkColorScheme = darkColorScheme( 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, @@ -62,6 +72,13 @@ fun DankChatTheme( dynamicColor && trueDarkTheme -> { dynamicDarkColorScheme(LocalContext.current).copy( surface = TrueDarkColorScheme.surface, + surfaceDim = TrueDarkColorScheme.surfaceDim, + surfaceBright = TrueDarkColorScheme.surfaceBright, + surfaceContainerLowest = TrueDarkColorScheme.surfaceContainerLowest, + surfaceContainerLow = TrueDarkColorScheme.surfaceContainerLow, + surfaceContainer = TrueDarkColorScheme.surfaceContainer, + surfaceContainerHigh = TrueDarkColorScheme.surfaceContainerHigh, + surfaceContainerHighest = TrueDarkColorScheme.surfaceContainerHighest, background = TrueDarkColorScheme.background, ) } From 6cc0042a8a6b30dcd33d062d82c65b8d19a73557 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 22:09:17 +0200 Subject: [PATCH 226/349] chore: Bump version to 4.0.2 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0f620666a..333d0d527 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40001 - versionName = "4.0.1" + versionCode = 40002 + versionName = "4.0.2" } androidResources { generateLocaleConfig = true } From 3d012557371b6a96663bae4828ff9a7e5695d7bd Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 31 Mar 2026 23:59:37 +0200 Subject: [PATCH 227/349] feat(theme): Add custom accent color and palette style settings with materialkolor --- app/build.gradle.kts | 1 + .../com/flxrs/dankchat/DankChatViewModel.kt | 1 - .../appearance/AppearanceSettings.kt | 44 +++ .../appearance/AppearanceSettingsScreen.kt | 288 ++++++++++++++++-- .../appearance/AppearanceSettingsState.kt | 8 + .../appearance/AppearanceSettingsViewModel.kt | 2 + .../dankchat/ui/chat/ChatMessageMapper.kt | 24 +- .../chat/messages/common/AdaptiveTextColor.kt | 6 +- .../chat/messages/common/BackgroundColor.kt | 1 - .../flxrs/dankchat/ui/main/MainActivity.kt | 39 +-- .../flxrs/dankchat/ui/theme/DankChatTheme.kt | 118 +++---- .../utils/extensions/ActivityExtensions.kt | 7 +- .../utils/extensions/BottomSheetExtensions.kt | 55 ---- .../main/res/values-b+zh+Hant+TW/strings.xml | 38 ++- app/src/main/res/values-be-rBY/strings.xml | 38 ++- app/src/main/res/values-ca/strings.xml | 38 ++- app/src/main/res/values-cs/strings.xml | 38 ++- app/src/main/res/values-de-rDE/strings.xml | 38 ++- app/src/main/res/values-en-rAU/strings.xml | 38 ++- app/src/main/res/values-en-rGB/strings.xml | 38 ++- app/src/main/res/values-en/strings.xml | 38 ++- app/src/main/res/values-es-rES/strings.xml | 38 ++- app/src/main/res/values-fi-rFI/strings.xml | 38 ++- app/src/main/res/values-fr-rFR/strings.xml | 38 ++- app/src/main/res/values-hu-rHU/strings.xml | 38 ++- app/src/main/res/values-it/strings.xml | 38 ++- app/src/main/res/values-ja-rJP/strings.xml | 38 ++- app/src/main/res/values-kk-rKZ/strings.xml | 38 ++- app/src/main/res/values-night/themes.xml | 13 - app/src/main/res/values-or-rIN/strings.xml | 38 ++- app/src/main/res/values-pl-rPL/strings.xml | 38 ++- app/src/main/res/values-pt-rBR/strings.xml | 38 ++- app/src/main/res/values-pt-rPT/strings.xml | 38 ++- app/src/main/res/values-ru-rRU/strings.xml | 38 ++- app/src/main/res/values-sr/strings.xml | 38 ++- app/src/main/res/values-tr-rTR/strings.xml | 38 ++- app/src/main/res/values-uk-rUA/strings.xml | 38 ++- app/src/main/res/values/strings.xml | 38 ++- app/src/main/res/values/themes.xml | 10 - gradle/libs.versions.toml | 2 + 40 files changed, 1265 insertions(+), 266 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 333d0d527..c179d0856 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -210,6 +210,7 @@ dependencies { // Other implementation(libs.colorpicker.android) + implementation(libs.materialkolor) implementation(libs.process.phoenix) implementation(libs.autolinktext) implementation(libs.aboutlibraries.compose.m3) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 801fde755..313c4118c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -31,7 +31,6 @@ class DankChatViewModel( .map { it.isLoggedIn } .distinctUntilChanged() - val isTrueDarkModeEnabled get() = appearanceSettingsDataStore.current().trueDarkTheme val keepScreenOn = appearanceSettingsDataStore.settings .map { it.keepScreenOn } 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 35d3d84fe..b0382542f 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,5 +1,9 @@ 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 @@ -17,6 +21,8 @@ enum class InputAction { data class AppearanceSettings( val theme: ThemePreference = ThemePreference.System, val trueDarkTheme: Boolean = false, + val accentColor: AccentColor? = null, + val paletteStyle: PaletteStylePreference = PaletteStylePreference.TonalSpot, val fontSize: Int = 14, val keepScreenOn: Boolean = true, val lineSeparator: Boolean = false, @@ -37,3 +43,41 @@ data class AppearanceSettings( ) enum class ThemePreference { System, Dark, Light } + +@Immutable +@Serializable +enum class PaletteStylePreference( + @StringRes val labelRes: Int, + @StringRes val descriptionRes: Int, + val isStandard: Boolean = true, +) { + 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/AppearanceSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt index 57ebb2878..b08cc89ae 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -1,43 +1,70 @@ package com.flxrs.dankchat.preferences.appearance -import android.app.Activity import android.content.Context -import androidx.activity.compose.LocalActivity 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.core.app.ActivityCompat +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.AutoDisableInput @@ -49,6 +76,7 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.S 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 @@ -107,6 +135,8 @@ private fun AppearanceSettingsContent( ThemeCategory( theme = settings.theme, trueDarkTheme = settings.trueDarkTheme, + accentColor = settings.accentColor, + paletteStyle = settings.paletteStyle, onInteraction = onSuspendingInteraction, ) HorizontalDivider(thickness = Dp.Hairline) @@ -201,18 +231,21 @@ private fun DisplayCategory( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun ThemeCategory( theme: ThemePreference, trueDarkTheme: Boolean, + accentColor: AccentColor?, + paletteStyle: PaletteStylePreference, onInteraction: suspend (AppearanceSettingsInteraction) -> Unit, ) { val scope = rememberCoroutineScope() val themeState = rememberThemeState(theme, trueDarkTheme, isSystemInDarkTheme()) + val hasCustomAccent = accentColor != null PreferenceCategory( title = stringResource(R.string.preference_theme_title), ) { - val activity = LocalActivity.current PreferenceListDialog( title = stringResource(R.string.preference_theme_title), summary = themeState.summary, @@ -222,24 +255,28 @@ private fun ThemeCategory( selected = themeState.preference, onChange = { scope.launch { - activity ?: return@launch onInteraction(Theme(it)) - setDarkMode(it, activity) + setDarkMode(it) } }, ) + AccentColorPicker( + selectedColor = accentColor, + onColorSelect = { color -> + scope.launch { onInteraction(AppearanceSettingsInteraction.SetAccentColor(color)) } + }, + ) + PaletteStyleDialog( + paletteStyle = paletteStyle, + isEnabled = 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 { - activity ?: return@launch - onInteraction(TrueDarkTheme(it)) - ActivityCompat.recreate(activity) - } - }, + onClick = { scope.launch { onInteraction(TrueDarkTheme(it)) } }, ) } } @@ -298,16 +335,231 @@ private fun getFontSizeSummary( else -> context.getString(R.string.preference_font_size_summary_very_large) } -private fun setDarkMode( - themePreference: ThemePreference, - activity: Activity, +@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, + isEnabled: Boolean, + onChange: (PaletteStylePreference) -> Unit, +) { + val scope = rememberCoroutineScope() + ExpandablePreferenceItem( + title = stringResource(R.string.preference_palette_style_title), + summary = stringResource(paletteStyle.labelRes), + isEnabled = isEnabled, + ) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val standardStyles = remember { PaletteStylePreference.entries.filter { it.isStandard } } + 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(themePreference: ThemePreference) { AppCompatDelegate.setDefaultNightMode( when (themePreference) { ThemePreference.System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM ThemePreference.Dark -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_NO + ThemePreference.Light -> AppCompatDelegate.MODE_NIGHT_NO }, ) - ActivityCompat.recreate(activity) } 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 index 747bfe285..7637a8415 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -42,6 +42,14 @@ sealed interface AppearanceSettingsInteraction { data class SwipeNavigation( val value: Boolean, ) : AppearanceSettingsInteraction + + data class SetAccentColor( + val color: AccentColor?, + ) : AppearanceSettingsInteraction + + data class SetPaletteStyle( + val style: PaletteStylePreference, + ) : AppearanceSettingsInteraction } @Immutable 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 50c31685a..fd9da2844 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 @@ -36,6 +36,8 @@ class AppearanceSettingsViewModel( is AppearanceSettingsInteraction.ShowChangelogs -> dataStore.update { it.copy(showChangelogs = interaction.value) } is AppearanceSettingsInteraction.ShowCharacterCounter -> dataStore.update { it.copy(showCharacterCounter = 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) } } } } 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 index bd02eb916..ed340b814 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -30,7 +30,6 @@ 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 com.google.android.material.color.MaterialColors import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import org.koin.core.annotation.Single @@ -875,24 +874,9 @@ class ChatMessageMapper( return TWITCH_USERNAME_COLORS[colorSeed % TWITCH_USERNAME_COLORS.size] } - // Checkered background colors - private val CHECKERED_LIGHT = - Color( - android.graphics.Color.argb( - (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), - 0, - 0, - 0, - ), - ) - private val CHECKERED_DARK = - Color( - android.graphics.Color.argb( - (255 * MaterialColors.ALPHA_DISABLED_LOW).toInt(), - 255, - 255, - 255, - ), - ) + // 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)) } } 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 index eb066744a..b05b2b634 100644 --- 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 @@ -9,7 +9,7 @@ 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.google.android.material.color.MaterialColors +import com.materialkolor.ktx.isLight /** * Resolves the effective opaque background for contrast calculations. @@ -27,7 +27,7 @@ private fun resolveEffectiveBackground(backgroundColor: Color): Color { /** * Returns appropriate text color (light or dark) based on background brightness. - * Uses MaterialColors.isColorLight() to determine if background is light, + * 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 @@ -38,7 +38,7 @@ fun rememberAdaptiveTextColor(backgroundColor: Color): Color { val adaptiveColors = LocalAdaptiveColors.current val effectiveBackground = resolveEffectiveBackground(backgroundColor) - val isLightBackground = MaterialColors.isColorLight(effectiveBackground.toArgb()) + val isLightBackground = effectiveBackground.isLight() return if (isLightBackground) { adaptiveColors.onSurfaceLight 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 index 402e7fbc6..fde563bce 100644 --- 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 @@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver -import com.google.android.material.color.MaterialColors /** * Selects the appropriate background color based on current theme. 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 index c525f5ae8..3f976c744 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -11,12 +11,12 @@ import android.os.IBinder import android.provider.MediaStore import android.util.Log import android.webkit.MimeTypeMap +import androidx.activity.ComponentActivity 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.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -75,8 +75,6 @@ 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 com.google.android.material.color.DynamicColors -import com.google.android.material.color.DynamicColorsOptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -86,7 +84,7 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.IOException -class MainActivity : AppCompatActivity() { +class MainActivity : ComponentActivity() { private val viewModel: DankChatViewModel by viewModel() private val dankChatPreferenceStore: DankChatPreferenceStore by inject() private val mainEventBus: MainEventBus by inject() @@ -139,39 +137,6 @@ class MainActivity : AppCompatActivity() { private var isBound = false 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) - } - } - enableEdgeToEdge() window.isNavigationBarContrastEnforced = false 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 index bd2bb80ab..f1017964c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -11,32 +11,21 @@ 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 -// True dark surface tones based on achromatic M3 neutral palette, compressed toward black. -// Standard dark tones: 0, 4, 4, 6, 9, 12, 15, 18 -// True dark tones: 0, 0, 0, 2, 4, 6, 8, 10 -private val TrueDarkColorScheme = - darkColorScheme( - 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, - ) - data class AdaptiveColors( val onSurfaceLight: Color, val onSurfaceDark: Color, @@ -51,57 +40,62 @@ val LocalAdaptiveColors = } @Composable -fun DankChatTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit, -) { +fun DankChatTheme(content: @Composable () -> Unit) { val inspectionMode = LocalInspectionMode.current val appearanceSettings = if (!inspectionMode) koinInject() else null - val trueDarkTheme = remember { appearanceSettings?.current()?.trueDarkTheme == true } + val settings by appearanceSettings?.settings?.collectAsStateWithLifecycle( + initialValue = remember { appearanceSettings.current() }, + ) ?: remember { androidx.compose.runtime.mutableStateOf(AppearanceSettings()) } - // Dynamic color is available on Android 12+ + 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 lightColorScheme = - when { - dynamicColor -> dynamicLightColorScheme(LocalContext.current) - else -> expressiveLightColorScheme() - } - val darkColorScheme = - when { - dynamicColor && trueDarkTheme -> { - dynamicDarkColorScheme(LocalContext.current).copy( - surface = TrueDarkColorScheme.surface, - surfaceDim = TrueDarkColorScheme.surfaceDim, - surfaceBright = TrueDarkColorScheme.surfaceBright, - surfaceContainerLowest = TrueDarkColorScheme.surfaceContainerLowest, - surfaceContainerLow = TrueDarkColorScheme.surfaceContainerLow, - surfaceContainer = TrueDarkColorScheme.surfaceContainer, - surfaceContainerHigh = TrueDarkColorScheme.surfaceContainerHigh, - surfaceContainerHighest = TrueDarkColorScheme.surfaceContainerHighest, - background = TrueDarkColorScheme.background, - ) - } + val lightColorScheme = when { + accentColor != null -> rememberDynamicColorScheme( + seedColor = accentColor.seedColor, + isDark = false, + style = paletteStyle, + ) - dynamicColor -> { - dynamicDarkColorScheme(LocalContext.current) - } + dynamicColor -> dynamicLightColorScheme(LocalContext.current) - else -> { - darkColorScheme() - } - } + else -> expressiveLightColorScheme() + } + + val darkColorScheme = when { + accentColor != null -> rememberDynamicColorScheme( + seedColor = accentColor.seedColor, + isDark = true, + isAmoled = trueDarkTheme, + style = paletteStyle, + ) + + dynamicColor && trueDarkTheme -> rememberDynamicColorScheme( + seedColor = dynamicDarkColorScheme(LocalContext.current).primary, + isDark = true, + isAmoled = true, + style = paletteStyle, + ) + + dynamicColor -> dynamicDarkColorScheme(LocalContext.current) + + else -> darkColorScheme() + } val adaptiveColors = AdaptiveColors( onSurfaceLight = lightColorScheme.onSurface, onSurfaceDark = darkColorScheme.onSurface, ) - val colors = - when { - darkTheme -> darkColorScheme - else -> lightColorScheme - } + val colors = if (darkTheme) darkColorScheme else lightColorScheme MaterialExpressiveTheme( motionScheme = MotionScheme.expressive(), @@ -112,3 +106,15 @@ fun DankChatTheme( } } } + +private fun PaletteStylePreference.toPaletteStyle(): PaletteStyle = when (this) { + 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/utils/extensions/ActivityExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ActivityExtensions.kt index 721f8c0d1..c141bf28f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ActivityExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ActivityExtensions.kt @@ -2,10 +2,9 @@ package com.flxrs.dankchat.utils.extensions import android.os.Build import android.view.WindowManager -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.FragmentActivity +import androidx.activity.ComponentActivity -fun AppCompatActivity.keepScreenOn(keep: Boolean) { +fun ComponentActivity.keepScreenOn(keep: Boolean) { if (keep) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -13,5 +12,5 @@ fun AppCompatActivity.keepScreenOn(keep: Boolean) { } } -val FragmentActivity.isInSupportedPictureInPictureMode: Boolean +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 63d8aff77..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt +++ /dev/null @@ -1,55 +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/res/values-b+zh+Hant+TW/strings.xml b/app/src/main/res/values-b+zh+Hant+TW/strings.xml index 48d2b25fd..45d8b924a 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 @@ -382,8 +382,42 @@ 顯示訊息輸入 顯示訊息輸入方框以便傳送訊息 使用系統預設 - 真黑暗模式 - 強制聊天室背景顏色設為黑色 + Amoled 深色模式 + 為 OLED 螢幕提供純黑背景 + 強調色 + 跟隨系統桌布 + 藍色 + 青色 + 綠色 + 萊姆色 + 黃色 + 橘色 + 紅色 + 粉紅色 + 紫色 + 靛色 + 棕色 + 灰色 + 色彩風格 + Tonal Spot + 沉穩柔和的色調 + Neutral + 近乎單色,淡雅色調 + Vibrant + 鮮豔飽和的色彩 + Expressive + 活潑的色彩搭配偏移色調 + Rainbow + 廣泛的色調光譜 + Fruit Salad + 活潑的多彩調色盤 + Monochrome + 僅有黑、白和灰色 + Fidelity + 忠於強調色 + Content + 強調色搭配類似的第三色 + 更多風格 顯示 元件 顯示被靜音的訊息 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index bb5e38009..87c634b01 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -247,8 +247,42 @@ Адлюстроўваць радок уводу Адлюстроўваць радок для ўводу паведамленняў У адпаведнасці з сістэмай - Сапраўдная цёмная тэма - Перамыкаць колер задняга фону чату на чорны + Amoled цёмны рэжым + Чыста чорны фон для OLED экранаў + Акцэнтны колер + Паводле сістэмных шпалер + Сіні + Бірузовы + Зялёны + Лаймавы + Жоўты + Аранжавы + Чырвоны + Ружовы + Фіялетавы + Індыга + Карычневы + Шэры + Стыль колераў + Tonal Spot + Спакойныя і прыглушаныя колеры + Neutral + Амаль манахромны, лёгкі адценне + Vibrant + Яркія і насычаныя колеры + Expressive + Гульнявыя колеры са зрушанымі адценнямі + Rainbow + Шырокі спектр адценняў + Fruit Salad + Гульнявая, шматколерная палітра + Monochrome + Толькі чорны, белы і шэры + Fidelity + Захоўвае дакладнасць акцэнтнага колеру + Content + Акцэнтны колер з аналагічным трацічным + Больш стыляў Адлюстраванне Кампаненты Адлюстроўваць выдаленыя паведамленні diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 8820ea3a1..0b3f140be 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -252,8 +252,42 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 + 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9c8c25741..867e39bb4 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -254,8 +254,42 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 + 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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index f547eb5d5..ef384725f 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -244,8 +244,42 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 + 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 599aff8ec..3d0191017 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -210,8 +210,42 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 1876abdac..dcd3fe6bc 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -210,8 +210,42 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index c70ac3324..0eaef99da 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -237,8 +237,42 @@ 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 + 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 grey only + Fidelity + Stays true to the accent color + Content + Accent color with analogous tertiary + More styles Display Components Show timed out messages diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index bd61186f0..2d4bab535 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -248,8 +248,42 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 + 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index b42f5446e..c20419733 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -244,8 +244,42 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 + 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 9a7f85224..936536ba2 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -247,8 +247,42 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 + 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 3fb6b0013..52e72ef0e 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -237,8 +237,42 @@ 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 + 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 65b0e2b38..eddda1e8b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -241,8 +241,42 @@ 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 + 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index d8d663b12..09cef539b 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -231,8 +231,42 @@ 入力を表示 メッセージを送信するための入力フィールドを表示します システムの既定値に設定 - トゥルーダークテーマ - チャット背景色を強制的に黒にする + AMOLEDダークモード + OLED画面向けの純粋な黒背景 + アクセントカラー + システムの壁紙に従う + + ティール + + ライム + + オレンジ + + ピンク + + インディゴ + + グレー + カラースタイル + Tonal Spot + 落ち着いた控えめな色合い + Neutral + ほぼモノクロ、微かな色味 + Vibrant + 大胆で鮮やかな色合い + Expressive + 色相をずらした遊び心のある色 + Rainbow + 幅広い色相のスペクトル + Fruit Salad + 遊び心のあるマルチカラーパレット + Monochrome + 黒、白、グレーのみ + Fidelity + アクセントカラーに忠実 + Content + 類似色の第三色を伴うアクセントカラー + その他のスタイル 表示 コンポーネント タイムアウトメッセージを表示 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 3b8a77ebe..74b2afaa3 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -380,8 +380,42 @@ Енгізуді көрсету Хабарларды жіберу үшін енгізу өрісін көрсетеді Жүйені әдепкі бақылау - Нағыз қараңғы тақырып - Фон түсін қара түске дейін мәжбүрлейді + Amoled қараңғы режим + OLED экрандар үшін таза қара фон + Акцент түсі + Жүйе тұсқағазына сәйкес + Көк + Көгілдір + Жасыл + Лайм + Сары + Қызғылт сары + Қызыл + Қызғылт + Күлгін + Индиго + Қоңыр + Сұр + Түс стилі + Tonal Spot + Тыныш және бәсең түстер + Neutral + Дерлік монохромды, сәл реңк + Vibrant + Жарқын және қанық түстер + Expressive + Ойнақы түстер ығысқан реңктермен + Rainbow + Реңктердің кең спектрі + Fruit Salad + Ойнақы, көп түсті палитра + Monochrome + Тек қара, ақ және сұр + Fidelity + Акцент түсіне адал қалады + Content + Акцент түсі ұқсас үштік түспен + Көбірек стильдер Дисплей Компоненттер Уақыт бойынша жазылған хабарларды көрсету diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 7a823a4f7..822008e7d 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -7,18 +7,5 @@ @android:color/transparent false shortEdges - @style/Widget.App.Chip - - - - - diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index e7b9008f9..0b1ccb13a 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -380,8 +380,42 @@ ଇନପୁଟ୍ ଦେଖାନ୍ତୁ | ବାର୍ତ୍ତା ପଠାଇବା ପାଇଁ ଇନପୁଟ୍ ଫିଲ୍ଡ ପ୍ରଦର୍ଶିତ କରେ | ସିଷ୍ଟମ୍ ଡିଫଲ୍ଟକୁ ଅନୁସରଣ କରିବା - ପ୍ରକୃତ ଅନ୍ଧାର ଥିମ୍ | - ଚାଟ୍ ପୃଷ୍ଠଭୂମି ରଙ୍ଗକୁ କଳାକୁ ବାଧ୍ୟ କରିଥାଏ | + Amoled ଗାଢ଼ ମୋଡ୍ + OLED ସ୍କ୍ରିନ୍ ପାଇଁ ସମ୍ପୂର୍ଣ୍ଣ କଳା ପୃଷ୍ଠଭୂମି + ଆକ୍ସେଣ୍ଟ ରଙ୍ଗ + ସିଷ୍ଟମ୍ ୱାଲପେପର୍ ଅନୁସରଣ କରନ୍ତୁ + ନୀଳ + ଟିଲ୍ + ସବୁଜ + ଲାଇମ୍ + ହଳଦିଆ + କମଳା + ଲାଲ୍ + ଗୋଲାପୀ + ବାଇଗଣୀ + ଇଣ୍ଡିଗୋ + ବାଦାମୀ + ଧୂସର + ରଙ୍ଗ ଶୈଳୀ + Tonal Spot + ଶାନ୍ତ ଏବଂ ମୃଦୁ ରଙ୍ଗ + Neutral + ପ୍ରାୟ ଏକରଙ୍ଗୀ, ସୂକ୍ଷ୍ମ ରଙ୍ଗ + Vibrant + ଉଜ୍ଜ୍ୱଳ ଏବଂ ଗାଢ଼ ରଙ୍ଗ + Expressive + ସ୍ଥାନାନ୍ତରିତ ରଙ୍ଗ ସହ ଖେଳପୂର୍ଣ୍ଣ ରଙ୍ଗ + Rainbow + ରଙ୍ଗର ବ୍ୟାପକ ସ୍ପେକ୍ଟ୍ରମ୍ + Fruit Salad + ଖେଳପୂର୍ଣ୍ଣ, ବହୁରଙ୍ଗୀ ପ୍ୟାଲେଟ୍ + Monochrome + କେବଳ କଳା, ଧଳା ଏବଂ ଧୂସର + Fidelity + ଆକ୍ସେଣ୍ଟ ରଙ୍ଗ ପ୍ରତି ବିଶ୍ୱସ୍ତ + Content + ସମାନ ତୃତୀୟ ସହ ଆକ୍ସେଣ୍ଟ ରଙ୍ଗ + ଅଧିକ ଶୈଳୀ ପ୍ରଦର୍ଶନ ଉପାଦାନଗୁଡ଼ିକ | ସମୟ ସମାପ୍ତ ବାର୍ତ୍ତା ଦେଖାନ୍ତୁ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index ac8817196..5ab1d2062 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -251,8 +251,42 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7bbce4ae0..0f9a27ba9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -242,8 +242,42 @@ 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 + 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 9cd4f7979..459f3bbd8 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -242,8 +242,42 @@ 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 + 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 83b843484..068e02c94 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -252,8 +252,42 @@ Отображать строку ввода Отображать строку для ввода сообщений В соответствии с системой - Истинно тёмная тема - Переключать цвет заднего фона чата на чёрный + AMOLED тёмный режим + Чисто чёрный фон для OLED-экранов + Цвет акцента + Следовать обоям системы + Синий + Бирюзовый + Зелёный + Лаймовый + Жёлтый + Оранжевый + Красный + Розовый + Фиолетовый + Индиго + Коричневый + Серый + Стиль цвета + Tonal Spot + Спокойные и приглушённые цвета + Neutral + Почти монохромный, едва заметный оттенок + Vibrant + Яркие и насыщенные цвета + Expressive + Игривые цвета со сдвинутыми оттенками + Rainbow + Широкий спектр оттенков + Fruit Salad + Игривая многоцветная палитра + Monochrome + Только чёрный, белый и серый + Fidelity + Верен цвету акцента + Content + Цвет акцента с аналоговым третичным + Больше стилей Отображение Компоненты Отображать удалённые сообщения diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 13339eed7..08a2c1cc6 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -342,8 +342,42 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index f03a1ff56..57a4550d4 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -243,8 +243,42 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 + 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index cd96ac410..8b6125c46 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -254,8 +254,42 @@ Показувати поле введення Показувати поле для введення тексту повідомлень За замовчуванням системи - Чорна тема - Змінює колір фону чату на чорний + AMOLED темний режим + Чисто чорний фон для OLED-екранів + Колір акценту + Слідувати шпалерам системи + Синій + Бірюзовий + Зелений + Лаймовий + Жовтий + Помаранчевий + Червоний + Рожевий + Фіолетовий + Індиго + Коричневий + Сірий + Стиль кольору + Tonal Spot + Спокійні та приглушені кольори + Neutral + Майже монохромний, ледь помітний відтінок + Vibrant + Яскраві та насичені кольори + Expressive + Грайливі кольори зі зміщеними відтінками + Rainbow + Широкий спектр відтінків + Fruit Salad + Грайлива багатокольорова палітра + Monochrome + Лише чорний, білий та сірий + Fidelity + Вірний кольору акценту + Content + Колір акценту з аналоговим третинним + Більше стилів Дисплей Елементи Показувати видалені повідомлення diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77f6f6872..da89ca145 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -426,8 +426,42 @@ 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 + 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 grey only + Fidelity + Stays true to the accent color + Content + Accent color with analogous tertiary + More styles Display Components show_timed_out_messages_key diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d962a6987..0ced6aa70 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -7,13 +7,8 @@ @android:color/transparent true shortEdges - @style/Widget.App.Chip - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe24b8622..3d62bcf4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,7 @@ autoLinkText = "2.0.2" processPhoenix = "3.0.0" colorPicker = "3.1.0" +materialKolor = "4.1.1" reorderable = "2.4.3" spotless = "8.4.0" @@ -132,6 +133,7 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx 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" } process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" } From 2118ea6bce9cbd9299ef9ead6e1c51f220128720 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 00:00:30 +0200 Subject: [PATCH 228/349] fix(i18n): Use American English "gray" in default and en locale strings --- app/src/main/res/values-en/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0eaef99da..2a12b11c7 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -267,7 +267,7 @@ Fruit Salad Playful, multi-colored palette Monochrome - Black, white, and grey only + Black, white, and gray only Fidelity Stays true to the accent color Content diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da89ca145..0d485e1e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,7 +456,7 @@ Fruit Salad Playful, multi-colored palette Monochrome - Black, white, and grey only + Black, white, and gray only Fidelity Stays true to the accent color Content From 9a9983a0e5d508ca8e894a70c07b1422bb5ecd4b Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 00:02:21 +0200 Subject: [PATCH 229/349] chore: Bump version to 4.0.3 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c179d0856..c57c9fcff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40002 - versionName = "4.0.2" + versionCode = 40003 + versionName = "4.0.3" } androidResources { generateLocaleConfig = true } From 15bef5766748028d226bfb04ae63e3b32fb55906 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 08:45:55 +0200 Subject: [PATCH 230/349] feat(suggestions): Add prefix-only suggestion mode, split chat settings into categories --- .../data/repo/command/CommandRepository.kt | 4 + .../dankchat/preferences/chat/ChatSettings.kt | 7 + .../preferences/chat/ChatSettingsDataStore.kt | 4 + .../preferences/chat/ChatSettingsScreen.kt | 147 ++++++++++++------ .../preferences/chat/ChatSettingsState.kt | 5 + .../preferences/chat/ChatSettingsViewModel.kt | 5 + .../components/PreferenceMultiListDialog.kt | 23 ++- .../ui/chat/suggestion/SuggestionProvider.kt | 30 +++- .../ui/main/input/ChatInputViewModel.kt | 20 ++- .../ui/main/input/SuggestionDropdown.kt | 2 + .../main/res/values-b+zh+Hant+TW/strings.xml | 13 +- app/src/main/res/values-be-rBY/strings.xml | 13 +- app/src/main/res/values-ca/strings.xml | 13 +- app/src/main/res/values-cs/strings.xml | 13 +- app/src/main/res/values-de-rDE/strings.xml | 13 +- app/src/main/res/values-en-rAU/strings.xml | 13 +- app/src/main/res/values-en-rGB/strings.xml | 13 +- app/src/main/res/values-en/strings.xml | 13 +- app/src/main/res/values-es-rES/strings.xml | 13 +- app/src/main/res/values-fi-rFI/strings.xml | 13 +- app/src/main/res/values-fr-rFR/strings.xml | 13 +- app/src/main/res/values-hu-rHU/strings.xml | 13 +- app/src/main/res/values-it/strings.xml | 13 +- app/src/main/res/values-ja-rJP/strings.xml | 13 +- app/src/main/res/values-kk-rKZ/strings.xml | 13 +- app/src/main/res/values-or-rIN/strings.xml | 13 +- app/src/main/res/values-pl-rPL/strings.xml | 13 +- app/src/main/res/values-pt-rBR/strings.xml | 13 +- app/src/main/res/values-pt-rPT/strings.xml | 13 +- app/src/main/res/values-ru-rRU/strings.xml | 13 +- app/src/main/res/values-sr/strings.xml | 13 +- app/src/main/res/values-tr-rTR/strings.xml | 13 +- app/src/main/res/values-uk-rUA/strings.xml | 13 +- app/src/main/res/values/strings.xml | 13 +- 34 files changed, 466 insertions(+), 93 deletions(-) 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 548a68f24..4d40983aa 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 @@ -83,6 +83,10 @@ class CommandRepository( else -> commandTriggers } + fun getCustomCommandTriggers(): Flow> = chatSettingsDataStore.commands.map { commands -> + commands.map(CustomCommand::trigger) + } + fun getSupibotCommands(channel: UserName): StateFlow> = supibotCommands.getOrPut(channel) { MutableStateFlow(emptyList()) } @Suppress("ReturnCount") 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 a9dce3656..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 @@ -9,6 +9,7 @@ import kotlin.uuid.Uuid @Serializable data class ChatSettings( 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, @@ -83,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 99a8d983e..b506ecce8 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 @@ -242,6 +242,10 @@ class ChatSettingsDataStore( settings .map { it.suggestionTypes } .distinctUntilChanged() + val suggestionMode = + settings + .map { it.suggestionMode } + .distinctUntilChanged() val showChatModes = settings .map { it.showChatModes } 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 5a4038b53..a3edc7992 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 @@ -128,20 +128,33 @@ private fun ChatSettingsScreen( .padding(padding) .verticalScroll(rememberScrollState()), ) { - GeneralCategory( + SuggestionsCategory( suggestionTypes = settings.suggestionTypes, - animateGifs = settings.animateGifs, + suggestionMode = settings.suggestionMode, + onNavToCommands = onNavToCommands, + onInteraction = onInteraction, + ) + HorizontalDivider(thickness = Dp.Hairline) + MessagesCategory( scrollbackLength = settings.scrollbackLength, - showUsernames = settings.showUsernames, - userLongClickBehavior = settings.userLongClickBehavior, - colorizeNicknames = settings.colorizeNicknames, 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) @@ -170,49 +183,62 @@ private fun ChatSettingsScreen( } @Composable -private fun GeneralCategory( +private fun SuggestionsCategory( suggestionTypes: ImmutableList, - animateGifs: Boolean, - scrollbackLength: Int, - showUsernames: Boolean, - userLongClickBehavior: UserLongClickBehavior, - colorizeNicknames: Boolean, - showTimedOutMessages: Boolean, - showTimestamps: Boolean, - timestampFormat: String, - visibleBadges: ImmutableList, - visibleEmotes: ImmutableList, + suggestionMode: SuggestionMode, onNavToCommands: () -> Unit, - onNavToUserDisplays: () -> Unit, onInteraction: (ChatSettingsInteraction) -> Unit, ) { - PreferenceCategory(title = stringResource(R.string.preference_general_header)) { + 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), values = remember { SuggestionType.entries.toImmutableList() }, initialSelected = suggestionTypes, entries = suggestionEntries, + descriptions = suggestionDescriptions, onChange = { onInteraction(ChatSettingsInteraction.SuggestionTypes(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), @@ -224,13 +250,42 @@ private fun GeneralCategory( 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() } @@ -242,51 +297,43 @@ private fun GeneralCategory( selected = userLongClickBehavior, 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, - onChange = { onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, + title = stringResource(R.string.preference_animate_gifs_title), + isChecked = animateGifs, + onClick = { onInteraction(ChatSettingsInteraction.AnimateGifs(it)) }, ) - - val entries = + 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, + entries = badgeEntries, onChange = { onInteraction(ChatSettingsInteraction.Badges(it)) }, ) PreferenceMultiListDialog( 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 index 23b7366f4..73375f17e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt @@ -12,6 +12,10 @@ sealed interface ChatSettingsInteraction { val value: List, ) : ChatSettingsInteraction + data class SuggestionModeChange( + val value: SuggestionMode, + ) : ChatSettingsInteraction + data class CustomCommands( val value: List, ) : ChatSettingsInteraction @@ -84,6 +88,7 @@ sealed interface ChatSettingsInteraction { @Immutable data class ChatSettingsState( val suggestionTypes: ImmutableList, + val suggestionMode: SuggestionMode, val customCommands: ImmutableList, val animateGifs: Boolean, val scrollbackLength: Int, 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 abd73b860..f13a7a4a4 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 @@ -37,6 +37,10 @@ class ChatSettingsViewModel( 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) } } @@ -117,6 +121,7 @@ class ChatSettingsViewModel( private fun ChatSettings.toState() = ChatSettingsState( suggestionTypes = suggestionTypes.toImmutableList(), + suggestionMode = suggestionMode, customCommands = customCommands.toImmutableList(), animateGifs = animateGifs, scrollbackLength = scrollbackLength, 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 7d4aa78a9..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 @@ -36,6 +37,7 @@ fun PreferenceMultiListDialog( isEnabled: Boolean = true, summary: String? = null, icon: ImageVector? = null, + descriptions: ImmutableList? = null, ) { var selected by remember(initialSelected) { mutableStateOf(values.map(initialSelected::contains).toPersistentList()) } ExpandablePreferenceItem( @@ -73,12 +75,21 @@ fun PreferenceMultiListDialog( onCheckedChange = { selected = selected.set(idx, it) }, interactionSource = interactionSource, ) - Text( - text = entry, - 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/ui/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt index 2bb211e39..36e8d4f6b 100644 --- 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 @@ -28,6 +28,7 @@ class SuggestionProvider( cursorPosition: Int, channel: UserName?, enabledTypes: List, + prefixOnly: Boolean = false, ): Flow> { if (inputText.isBlank() || channel == null || enabledTypes.isEmpty()) { return flowOf(emptyList()) @@ -51,10 +52,7 @@ class SuggestionProvider( else -> currentWord } - if (isEmoteTrigger && emoteQuery.isEmpty()) { - return flowOf(emptyList()) - } - if (!isEmoteTrigger && currentWord.length < MIN_SUGGESTION_CHARS) { + if ((isEmoteTrigger && emoteQuery.isEmpty()) || (!isEmoteTrigger && currentWord.length < MIN_SUGGESTION_CHARS)) { return flowOf(emptyList()) } @@ -72,7 +70,7 @@ class SuggestionProvider( } } - // Commands only when prefix matches a command trigger character + // 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 -> @@ -80,7 +78,15 @@ class SuggestionProvider( } } - // General: score enabled types together + // 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()) @@ -89,8 +95,12 @@ class SuggestionProvider( usersEnabled -> getScoredUserSuggestions(channel, currentWord) else -> flowOf(emptyList()) } - return combine(emoteFlow, userFlow) { emotes, users -> - mergeSorted(emotes, users) + return combine(emoteFlow, userFlow, customCommandFlow) { emotes, users, customCommands -> + val merged = mergeSorted(emotes, users) + when { + customCommands.isEmpty() -> merged + else -> merged + customCommands + } } } @@ -116,6 +126,10 @@ class SuggestionProvider( filterUsers(displayNameSet, constraint) } + private fun getCustomCommandSuggestions(constraint: String): Flow> = commandRepository.getCustomCommandTriggers().map { triggers -> + filterCommands(triggers, constraint) + } + private fun getCommandSuggestions( channel: UserName, constraint: String, 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 index 83427cd20..0068e80fb 100644 --- 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 @@ -23,6 +23,8 @@ 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 @@ -112,11 +114,11 @@ class ChatInputViewModel( debouncedTextAndCursor, chatChannelProvider.activeChannel, chatSettingsDataStore.suggestionTypes, - ) { (text, cursorPos), channel, enabledTypes -> - Triple(text, cursorPos, channel) to enabledTypes - }.flatMapLatest { (triple, enabledTypes) -> - val (text, cursorPos, channel) = triple - suggestionProvider.getSuggestions(text, cursorPos, channel, enabledTypes) + 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()) @@ -569,6 +571,14 @@ internal fun computeSuggestionReplacement( ) } +private data class SuggestionInput( + val text: String, + val cursorPos: Int, + val channel: UserName?, + val enabledTypes: List, + val prefixOnly: Boolean, +) + private data class UiDependencies( val text: String, val suggestions: List, 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 index 14e40f7b6..b6d88cbc9 100644 --- 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 @@ -12,6 +12,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -90,6 +91,7 @@ fun SuggestionDropdown( ) { LazyColumn( state = listState, + contentPadding = PaddingValues(vertical = 4.dp), modifier = Modifier .fillMaxWidth() 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 45d8b924a..0e855c3cd 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 @@ -376,6 +376,13 @@ 提醒 聊天 一般 + 建議 + 訊息 + 用戶 + 表情與徽章 + 建議模式 + 輸入時建議匹配項目 + 僅在觸發字元後建議 關於 樣式 DankChat %1$s 是由 @flex3rs 及其貢獻者所製 @@ -432,9 +439,13 @@ 建議 選擇輸入時顯示的建議類型 表情 + 以 : 觸發 用戶 - 指令 + 以 @ 觸發 + Twitch 指令 + 以 / 觸發 Supibot指令 + 以 $ 觸發 啟動時自動讀取聊天紀錄 在重新連接後載入訊息歷史 嘗試捕捉在連接斷開時漏掉的訊息 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 87c634b01..8a2cffe2f 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -241,6 +241,13 @@ Апавяшчэнні Чат Агульныя + Падказкі + Паведамленні + Карыстальнікі + Эмоцыі і значкі + Рэжым падказак + Прапаноўваць супадзенні падчас уводу + Прапаноўваць толькі пасля сімвала-трыгера Пра праграму Выгляд DankChat %1$s створаны @flex3rs і іншымі ўдзельнікамі @@ -297,9 +304,13 @@ Падказкі Абярыце, якія падказкі паказваць пры ўводзе Эмоцыі + Выклікаць з : Карыстальнікі - Каманды + Выклікаць з @ + Каманды Twitch + Выклікаць з / Каманды Supibot + Выклікаць з $ Загружаць гісторыю паведамленняў адразу Загрузіць гісторыю паведамленняў пасля паўторнага падключэння Спрабаваць атрымаць прапушчаныя паведамленні, якія не былі атрыманы падчас разрыву злучэння diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 0b3f140be..4d0620cd8 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -246,6 +246,13 @@ 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 @@ -302,9 +309,13 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Suggeriments Tria quins suggeriments mostrar mentre escrius Emotes + Activar amb : Usuaris - Ordres + 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ó diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 867e39bb4..e1dc9fed8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -248,6 +248,10 @@ 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 @@ -305,8 +309,15 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Zvolte, které návrhy zobrazovat při psaní Emotikony Uživatelé - Příkazy + 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í diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index ef384725f..af4df3d61 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -240,6 +240,10 @@ 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 @@ -295,8 +299,15 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Wähle, welche Vorschläge beim Tippen angezeigt werden Emotes Benutzer - Befehle + 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 3d0191017..2ca8f2753 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -206,6 +206,10 @@ 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 @@ -260,8 +264,15 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Choose which suggestions to show while typing Emotes Users - Commands + 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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index dcd3fe6bc..ff3cf9e25 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -206,6 +206,10 @@ 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 @@ -260,8 +264,15 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Choose which suggestions to show while typing Emotes Users - Commands + 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2a12b11c7..739b246bc 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -233,6 +233,10 @@ 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 @@ -288,8 +292,15 @@ Choose which suggestions to show while typing Emotes Users - Commands + 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 2d4bab535..dd7a900fd 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -244,6 +244,10 @@ 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 @@ -299,8 +303,15 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Elige qué sugerencias mostrar al escribir Emotes Usuarios - Comandos + 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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index c20419733..938b9d812 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -238,6 +238,10 @@ 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 @@ -295,8 +299,15 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Valitse mitkä ehdotukset näytetään kirjoittaessa Emojit Käyttäjät - Komennot + 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 936536ba2..fdf19e709 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -243,6 +243,10 @@ 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 @@ -298,8 +302,15 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Choisir les suggestions à afficher lors de la saisie Emotes Utilisateurs - Commandes + 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 52e72ef0e..6ef071c28 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -231,6 +231,10 @@ É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 @@ -288,8 +292,15 @@ Válaszd ki, milyen javaslatokat mutasson gépelés közben Emoték Felhasználók - Parancsok + 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index eddda1e8b..91a91bdb4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -237,6 +237,10 @@ 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 @@ -292,8 +296,15 @@ Scegli quali suggerimenti mostrare durante la digitazione Emote Utenti - Comandi + 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 09cef539b..8df005d50 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -225,6 +225,10 @@ 通知 チャット 一般 + サジェスト + メッセージ + ユーザー + エモートとバッジ このアプリについて 外観 DankChat %1$sは@flex3rsと複数のコントリビューターによって作成されました @@ -282,8 +286,15 @@ 入力中に表示するサジェストを選択 エモート ユーザー - コマンド + Twitchコマンド Supibotコマンド + : で呼び出す + @ で呼び出す + / で呼び出す + $ で呼び出す + サジェストモード + 入力中に候補を表示 + トリガー文字の後にのみ候補を表示 開始時にメッセージ履歴を読み込む 再接続後にメッセージ履歴を読み込む 接続が切断中に受信されずに失われたメッセージを取得しようとしています diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 74b2afaa3..e06ef077e 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -374,6 +374,13 @@ Ескертулер Чат Жалпы + Ұсыныстар + Хабарлар + Пайдаланушылар + Эмоттар мен белгілер + Ұсыныс режимі + Теру кезінде сәйкестіктерді ұсыну + Тек триггер таңбасынан кейін ұсыну Шамамен Сыртқы көрінісі DankChat %1$s жылғы @flex3rs және салымшылар @@ -430,9 +437,13 @@ Ұсыныстар Теру кезінде қандай ұсыныстарды көрсету керектігін таңдаңыз Эмоттар + : арқылы іске қосу Пайдаланушылар - Командалар + @ арқылы іске қосу + Twitch командалары + / арқылы іске қосу Supibot командалары + $ арқылы іске қосу Хабар журналын бастауға жүктеу Қайта қосылғаннан кейін хабар журналын жүктеңіз Байланыс үзілген кезде қабылданбаған хабарларды алу әрекеті diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 0b1ccb13a..306f0fc78 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -374,6 +374,13 @@ ଵିଜ୍ଞପ୍ତି ଚାଟ୍ ସାଧାରଣ + ପରାମର୍ଶ + ବାର୍ତ୍ତା + ଉପଭୋକ୍ତା + ଇମୋଟ ଏବଂ ବ୍ୟାଜ୍ + ପରାମର୍ଶ ମୋଡ୍ + ଟାଇପ୍ କରିବା ସମୟରେ ମେଳ ପ୍ରସ୍ତାବ କରନ୍ତୁ + କେବଳ ଟ୍ରିଗର ଅକ୍ଷର ପରେ ପ୍ରସ୍ତାବ କରନ୍ତୁ ଵିଷୟରେ ରୂପ DankChat %1$s @ flex3rs ଏବଂ ସହଯୋଗୀମାନଙ୍କ ଦ୍ୱାରା ପ୍ରସ୍ତୁତ | @@ -430,9 +437,13 @@ ପରାମର୍ଶ ଟାଇପ୍ କରିବା ସମୟରେ କେଉଁ ପରାମର୍ଶ ଦେଖାଇବେ ବାଛନ୍ତୁ ଇମୋଟ + : ସହିତ ଟ୍ରିଗର କରନ୍ତୁ ଉପଭୋକ୍ତା - କಮାଣ୍ଡ + @ ସହିତ ଟ୍ରିଗର କରନ୍ତୁ + Twitch କମାଣ୍ଡ + / ସହିତ ଟ୍ରିଗର କରନ୍ତୁ Supibot କମାଣ୍ଡ + $ ସହିତ ଟ୍ରିଗର କରନ୍ତୁ ଆରମ୍ଭରେ ବାର୍ତ୍ତା ଇତିହାସ ଲୋଡ୍ କରନ୍ତୁ | ପୁନ recon ସଂଯୋଗ ପରେ ବାର୍ତ୍ତା ଇତିହାସ ଲୋଡ୍ କରନ୍ତୁ | ମିସ୍ ଡ୍ରପ୍ ସମୟରେ ଗ୍ରହଣ ହୋଇନଥିବା ସନ୍ଦେଶ ଆଣିବାକୁ ଚେଷ୍ଟା କରିବାକୁ ଚେଷ୍ଟା କରେ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 5ab1d2062..e864058ba 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -245,6 +245,10 @@ 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 @@ -302,8 +306,15 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Wybierz, które podpowiedzi wyświetlać podczas pisania Emotki Użytkownicy - Komendy + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0f9a27ba9..33e8d6c69 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -238,6 +238,10 @@ 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 @@ -293,8 +297,15 @@ Escolha quais sugestões mostrar ao digitar Emotes Usuários - Comandos + 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 459f3bbd8..1cf59e522 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -238,6 +238,10 @@ 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 @@ -293,8 +297,15 @@ Escolha quais sugestões mostrar ao escrever Emotes Utilizadores - Comandos + 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 068e02c94..2fd1e2214 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -246,6 +246,10 @@ Уведомления Чат Общие + Подсказки + Сообщения + Пользователи + Эмоции и значки О программе Внешний вид DankChat %1$s создан @flex3rs и другими участниками @@ -303,8 +307,15 @@ Выберите, какие подсказки показывать при вводе Эмоции Пользователи - Команды + Команды Twitch Команды Supibot + Активировать с помощью : + Активировать с помощью @ + Активировать с помощью / + Активировать с помощью $ + Режим подсказок + Предлагать совпадения при вводе + Предлагать только после символа-триггера Загружать историю сообщений сразу Загружать историю сообщений после переподключения Попытаться получить пропущенные сообщения, которые не были получены во время разрыва соединения diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 08a2c1cc6..a34b5f1e2 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -336,6 +336,13 @@ 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 @@ -392,9 +399,13 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Предлози Изаберите које предлоге приказати приликом куцања Емоте + Покрените са : Корисници - Команде + Покрените са @ + Twitch команде + Покрените са / Supibot команде + Покрените са $ Učitaj istoriju poruka na početku Учитај историју порука после поновног повезивања Покушава да преузме пропуштене поруке које нису примљене током прекида везе diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 57a4550d4..a2b629519 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -237,6 +237,10 @@ 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ı @@ -294,8 +298,15 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Yazarken hangi önerilerin gösterileceğini seçin İfadeler Kullanıcılar - Komutlar + 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 8b6125c46..7eccd51e8 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -248,6 +248,10 @@ Сповіщення Чат Загальні + Підказки + Повідомлення + Користувачі + Емоції та значки Про додаток Зовнішній вигляд DankChat %1$s зроблений @flex3rs та іншими учасниками @@ -305,8 +309,15 @@ Оберіть, які підказки показувати під час введення Емоції Користувачі - Команди + Команди Twitch Команди Supibot + Активувати за допомогою : + Активувати за допомогою @ + Активувати за допомогою / + Активувати за допомогою $ + Режим підказок + Пропонувати збіги під час введення + Пропонувати лише після символу-тригера Завантажувати історію повідомлень при запуску Завантаження історії повідомлень після повторного підключення Спроби отримати пропущені повідомлення, які не були отримані під час з\'єднання, обриваються diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d485e1e9..a4b6eb434 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -415,6 +415,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 @@ -482,9 +489,13 @@ Suggestions Choose which suggestions to show while typing Emotes + Trigger with : Users - Commands + 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 From b1b2f3fdcb0b957017d62107ad60aec67b8388d5 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 09:11:05 +0200 Subject: [PATCH 231/349] fix(about): Upgrade aboutlibraries to 14.0.0-b03 for AGP 9, use bottom sheet for license details --- .../dankchat/preferences/about/AboutScreen.kt | 96 ++++++++++++------- app/src/main/res/raw/keep.xml | 3 + gradle/libs.versions.toml | 2 +- 3 files changed, 64 insertions(+), 37 deletions(-) create mode 100644 app/src/main/res/raw/keep.xml 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 index cbd5d0015..3d5422285 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -1,25 +1,29 @@ 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.AlertDialog 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.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.getValue import androidx.compose.runtime.mutableStateOf @@ -32,7 +36,9 @@ 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.utils.compose.BottomSheetNestedScrollConnection import com.flxrs.dankchat.utils.compose.textLinkStyles import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library @@ -85,40 +91,58 @@ fun AboutScreen(onBack: () -> Unit) { 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()), - ) - }, - ) - } + 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, + ) { + Text( + text = library.name, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(12.dp)) + val scrollState = rememberScrollState() + Text( + text = license, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .weight(1f, fill = false) + .nestedScroll(BottomSheetNestedScrollConnection) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.navigationBarsPadding()) + } +} 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d62bcf4a..312b43168 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ okhttp = "5.3.2" ksp = "2.3.6" koin = "4.1.1" koin-annotations = "2.3.1" -about-libraries = "13.2.1" +about-libraries = "14.0.0-b03" androidGradlePlugin = "9.1.0" androidDesugarLibs = "2.1.5" From 95a915b2ac23d9f7f4a2884bdae42238cac9a025 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 09:26:26 +0200 Subject: [PATCH 232/349] fix(about): Make license bottom sheet edge-to-edge behind navigation bar --- .../com/flxrs/dankchat/preferences/about/AboutScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 3d5422285..8055a1ad3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -14,6 +14,7 @@ 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 @@ -125,6 +126,7 @@ private fun LibraryLicenseSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentWindowInsets = { BottomSheetDefaults.windowInsets.exclude(WindowInsets.navigationBars) }, ) { Text( text = library.name, @@ -132,6 +134,7 @@ private fun LibraryLicenseSheet( modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(Modifier.height(12.dp)) + val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val scrollState = rememberScrollState() Text( text = license, @@ -141,8 +144,7 @@ private fun LibraryLicenseSheet( .weight(1f, fill = false) .nestedScroll(BottomSheetNestedScrollConnection) .verticalScroll(scrollState) - .padding(horizontal = 16.dp), + .padding(start = 16.dp, end = 16.dp, bottom = navBarBottom), ) - Spacer(Modifier.navigationBarsPadding()) } } From c245527da278d31ef6d32635cce107ad0be9f9b3 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 10:11:15 +0200 Subject: [PATCH 233/349] fix(suggestions): Fix duplicate key crash, validate custom command triggers, align suggestion icons --- .../data/repo/command/CommandRepository.kt | 7 + .../chat/commands/CommandsScreen.kt | 21 +++ .../chat/commands/CommandsViewModel.kt | 10 +- .../ui/main/input/SuggestionDropdown.kt | 124 ++++++++++-------- app/src/main/res/values/strings.xml | 2 + 5 files changed, 107 insertions(+), 57 deletions(-) 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 4d40983aa..0cf473116 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 @@ -78,6 +78,13 @@ class CommandRepository( } } + 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 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 3261e0b51..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 @@ -64,6 +64,7 @@ fun CustomCommandsScreen(onNavBack: () -> Unit) { val commands = viewModel.commands.collectAsStateWithLifecycle().value CustomCommandsScreen( initialCommands = commands, + reservedTriggers = viewModel.reservedTriggers, onSaveAndNavBack = { viewModel.save(it) onNavBack() @@ -75,6 +76,7 @@ fun CustomCommandsScreen(onNavBack: () -> Unit) { @Composable private fun CustomCommandsScreen( initialCommands: ImmutableList, + reservedTriggers: Set, onSaveAndNavBack: (List) -> Unit, onSave: (List) -> Unit, ) { @@ -146,9 +148,16 @@ private fun CustomCommandsScreen( .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { 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, + triggerError = triggerError, onTriggerChange = { commands[idx] = command.copy(trigger = it) }, onCommandChange = { commands[idx] = command.copy(command = it) }, onRemove = { @@ -185,10 +194,16 @@ private fun CustomCommandsScreen( } } +private enum class TriggerError { + Reserved, + Duplicate, +} + @Composable private fun CustomCommandItem( trigger: String, command: String, + triggerError: TriggerError?, onTriggerChange: (String) -> Unit, onCommandChange: (String) -> Unit, onRemove: () -> Unit, @@ -208,6 +223,12 @@ private fun CustomCommandItem( value = trigger, 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, ) 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 6a7a31946..37475a4b2 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 @@ -16,6 +17,7 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class CommandsViewModel( private val chatSettingsDataStore: ChatSettingsDataStore, + private val commandRepository: CommandRepository, ) : ViewModel() { val commands = chatSettingsDataStore.settings @@ -26,8 +28,14 @@ class CommandsViewModel( 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/ui/main/input/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt index b6d88cbc9..64765a8f5 100644 --- 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 @@ -11,21 +11,24 @@ 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.Android 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 @@ -97,7 +100,18 @@ fun SuggestionDropdown( .fillMaxWidth() .animateContentSize(), ) { - items(suggestions, key = { it.toString() }) { suggestion -> + items( + suggestions, + key = { suggestion -> + when (suggestion) { + is Suggestion.EmoteSuggestion -> "emote-${suggestion.emote.id}" + is Suggestion.UserSuggestion -> "user-$suggestion" + is Suggestion.EmojiSuggestion -> "emoji-${suggestion.emoji.unicode}" + is Suggestion.CommandSuggestion -> "cmd-${suggestion.command}" + is Suggestion.FilterSuggestion -> "filter-${suggestion.keyword}" + } + }, + ) { suggestion -> SuggestionItem( suggestion = suggestion, onClick = { onSuggestionClick(suggestion) }, @@ -114,6 +128,7 @@ private fun SuggestionItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val iconSize = 36.dp Row( modifier = modifier @@ -122,78 +137,75 @@ private fun SuggestionItem( .padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Icon/Image based on suggestion type when (suggestion) { is Suggestion.EmoteSuggestion -> { AsyncImage( model = suggestion.emote.url, contentDescription = suggestion.emote.code, - modifier = - Modifier - .size(48.dp) - .padding(end = 12.dp), - ) - Text( - text = suggestion.emote.code, - style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.size(iconSize), ) } is Suggestion.UserSuggestion -> { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = - Modifier - .size(32.dp) - .padding(end = 12.dp), - ) - Text( - text = suggestion.name.value, - style = MaterialTheme.typography.bodyLarge, - ) + Box(modifier = Modifier.size(iconSize), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } } is Suggestion.EmojiSuggestion -> { - Text( - text = suggestion.emoji.unicode, - fontSize = 24.sp, - modifier = - Modifier - .size(32.dp) - .padding(end = 12.dp) - .wrapContentSize(), - ) - Text( - text = ":${suggestion.emoji.code}:", - style = MaterialTheme.typography.bodyLarge, - ) + Box(modifier = Modifier.size(iconSize), contentAlignment = Alignment.Center) { + Text( + text = suggestion.emoji.unicode, + fontSize = 24.sp, + ) + } } is Suggestion.CommandSuggestion -> { - Icon( - imageVector = Icons.Default.Android, - contentDescription = null, - modifier = - Modifier - .size(32.dp) - .padding(end = 12.dp), - ) - Text( - text = suggestion.command, - style = MaterialTheme.typography.bodyLarge, - ) + 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 -> { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = null, - modifier = - Modifier - .size(32.dp) - .padding(end = 12.dp), - ) Column { Text( text = suggestion.displayText ?: suggestion.keyword, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4b6eb434..8f849bd18 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -370,6 +370,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 From 7b2582cae1ac78ebc150451fb80ebd1777b000c9 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 10:22:31 +0200 Subject: [PATCH 234/349] fix(i18n): Add translations for custom command trigger validation errors --- app/src/main/res/values-b+zh+Hant+TW/strings.xml | 2 ++ app/src/main/res/values-be-rBY/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de-rDE/strings.xml | 2 ++ app/src/main/res/values-en-rAU/strings.xml | 2 ++ app/src/main/res/values-en-rGB/strings.xml | 2 ++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-fi-rFI/strings.xml | 2 ++ app/src/main/res/values-fr-rFR/strings.xml | 2 ++ app/src/main/res/values-hu-rHU/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja-rJP/strings.xml | 2 ++ app/src/main/res/values-kk-rKZ/strings.xml | 2 ++ app/src/main/res/values-or-rIN/strings.xml | 2 ++ app/src/main/res/values-pl-rPL/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt-rPT/strings.xml | 2 ++ app/src/main/res/values-ru-rRU/strings.xml | 2 ++ app/src/main/res/values-sr/strings.xml | 2 ++ app/src/main/res/values-tr-rTR/strings.xml | 2 ++ app/src/main/res/values-uk-rUA/strings.xml | 2 ++ 23 files changed, 46 insertions(+) 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 0e855c3cd..2e55c87c0 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 @@ -338,6 +338,8 @@ 新增指令 移除指令 觸發 + 此觸發器已被內建指令保留 + 此觸發器已被其他指令使用 指令 自訂指令 檢舉 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 8a2cffe2f..fe5cea35c 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -204,6 +204,8 @@ Дадаць каманду Выдаліць каманду Трыгер + Гэты трыгер зарэзерваваны ўбудаванай камандай + Гэты трыгер ужо выкарыстоўваецца іншай камандай Каманда Уласныя каманды Паскардзіцца diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 4d0620cd8..e0b2ab7a0 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -208,6 +208,8 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e1dc9fed8..40ca5245e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -210,6 +210,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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index af4df3d61..fef0fb4a8 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -200,6 +200,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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 2ca8f2753..3691d8448 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -184,6 +184,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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index ff3cf9e25..f15d9d3e9 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -184,6 +184,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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 739b246bc..f036ac9ec 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -194,6 +194,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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index dd7a900fd..ffea5dc45 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -204,6 +204,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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 938b9d812..97eaf480f 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -201,6 +201,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index fdf19e709..77399643b 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -204,6 +204,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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 6ef071c28..cc4b78844 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -194,6 +194,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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 91a91bdb4..8451c4710 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -198,6 +198,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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 8df005d50..ca8b614de 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -188,6 +188,8 @@ コマンドの追加 コマンドを削除 トリガー + このトリガーは組み込みコマンドによって予約されています + このトリガーは既に別のコマンドで使用されています コマンド カスタムコマンド 通報 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index e06ef077e..657337e5d 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -337,6 +337,8 @@ Пәрмен қосу Пәрменді жою Триггер + Бұл триггер кірістірілген пәрменмен сақталған + Бұл триггер басқа пәрменде қолданылуда Пәрмен Пайдаланушы командалары Есеп diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 306f0fc78..64e69b4dd 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -337,6 +337,8 @@ ଏକ କମାଣ୍ଡ ଯୋଡନ୍ତୁ କମାଣ୍ଡ ଅପସାରଣ କରନ୍ତୁ ଟ୍ରିଗର୍ + ଏହି ଟ୍ରିଗର୍ ଏକ ଅନ୍ତର୍ନିର୍ମିତ କମାଣ୍ଡ ଦ୍ୱାରା ସଂରକ୍ଷିତ + ଏହି ଟ୍ରିଗର୍ ଅନ୍ୟ ଏକ କମାଣ୍ଡ ଦ୍ୱାରା ବ୍ୟବହୃତ ହେଉଛି କମାଣ୍ଡ[ସମ୍ପାଦନା] କଷ୍ଟମ୍ କମାଣ୍ଡସ୍ ରିପୋର୍ଟ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index e864058ba..a4a49dd6f 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -208,6 +208,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ś diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 33e8d6c69..6ac02783e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -199,6 +199,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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 1cf59e522..d48ab76fd 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -199,6 +199,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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 2fd1e2214..e6f4644c7 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -209,6 +209,8 @@ Добавить команду Удалить команду Триггер + Этот триггер зарезервирован встроенной командой + Этот триггер уже используется другой командой Команда Пользовательские команды Пожаловаться diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index a34b5f1e2..e5e365c5d 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -300,6 +300,8 @@ 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 Додај корисника diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index a2b629519..9edcb818f 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -199,6 +199,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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 7eccd51e8..691b46aa2 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -210,6 +210,8 @@ Додати команду Видалити команду Тригер + Цей тригер зарезервований вбудованою командою + Цей тригер вже використовується іншою командою Команда Користувацькі команди Поскаржитися From 4a3336470e554406c69a897cfb61619bdfbd3466 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 11:00:37 +0200 Subject: [PATCH 235/349] fix(chat): Relax username color contrast ratio to 3.5:1, use best-effort fallback --- .../utils/extensions/ColorExtensions.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) 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 13c6782d4..c92782d33 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 @@ -6,9 +6,9 @@ import androidx.core.graphics.ColorUtils /** * Adjusts this color to ensure readable contrast against [background]. * - * Uses WCAG contrast ratio (target 4.5:1 for normal text) and shifts - * the color's lightness in HSL space until the target is met, - * preserving hue and saturation as much as possible. + * Uses a relaxed contrast ratio of 3.5:1 (below WCAG AA 4.5:1) to + * preserve the original color as much as possible while still being readable. + * Shifts lightness in HSL space, preserving hue and saturation. */ @ColorInt fun Int.normalizeColor( @@ -53,22 +53,20 @@ fun Int.normalizeColor( // 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 } } - if (bestContrast >= MIN_CONTRAST_RATIO) { - hsl[2] = bestL - return ColorUtils.HSLToColor(hsl) - } - - // Fallback: push to extreme lightness - hsl[2] = if (shouldLighten) 0.9f else 0.1f + hsl[2] = bestL return ColorUtils.HSLToColor(hsl) } -private const val MIN_CONTRAST_RATIO = 4.5 +private const val MIN_CONTRAST_RATIO = 3.5 private const val MAX_ITERATIONS = 16 /** convert int to RGB with zero pad */ From 94121c22b0c4053259b8ab43680e90f206f0595b Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 12:28:49 +0200 Subject: [PATCH 236/349] feat(chat): Add watch streak highlight, fix ignore label bug, only connect same-type highlights --- .../database/entity/MessageHighlightEntity.kt | 1 + .../database/entity/MessageIgnoreEntity.kt | 1 + .../data/repo/HighlightsRepository.kt | 12 ++++++++ .../dankchat/data/repo/IgnoresRepository.kt | 30 +++++++++++++++---- .../data/twitch/message/HighlightState.kt | 1 + .../data/twitch/message/PrivMessage.kt | 5 +++- .../data/twitch/message/UserNoticeMessage.kt | 6 +++- .../notifications/highlights/HighlightItem.kt | 3 ++ .../highlights/HighlightsScreen.kt | 2 ++ .../notifications/ignores/IgnoreItem.kt | 3 ++ .../notifications/ignores/IgnoresScreen.kt | 7 +++-- .../dankchat/ui/chat/ChatMessageMapper.kt | 25 ++++++++++++---- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 16 +++++----- .../main/res/values-b+zh+Hant+TW/strings.xml | 1 + app/src/main/res/values-be-rBY/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de-rDE/strings.xml | 1 + app/src/main/res/values-en-rAU/strings.xml | 1 + app/src/main/res/values-en-rGB/strings.xml | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu-rHU/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-kk-rKZ/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sr/strings.xml | 1 + app/src/main/res/values-tr-rTR/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 37 files changed, 112 insertions(+), 24 deletions(-) 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 63e51ef19..b8116ea91 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 @@ -50,6 +50,7 @@ enum class MessageHighlightEntityType { Username, Subscription, Announcement, + WatchStreak, ChannelPointRedemption, FirstMessage, ElevatedMessage, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageIgnoreEntity.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageIgnoreEntity.kt index f03d66865..146e3ddbe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageIgnoreEntity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageIgnoreEntity.kt @@ -54,6 +54,7 @@ data class MessageIgnoreEntity( enum class MessageIgnoreEntityType { Subscription, Announcement, + WatchStreak, ChannelPointRedemption, FirstMessage, ElevatedMessage, 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 aa74a5733..f4a922612 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 @@ -24,6 +24,7 @@ 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 @@ -224,6 +225,11 @@ class HighlightsRepository( 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( @@ -265,6 +271,11 @@ class HighlightsRepository( add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) } + val watchStreakHighlight = messageHighlights.ofType(MessageHighlightEntityType.WatchStreak) + if (isViewerMilestone && watchStreakHighlight != null) { + add(Highlight(HighlightType.WatchStreak, watchStreakHighlight.customColor)) + } + val rewardsHighlight = messageHighlights.ofType(MessageHighlightEntityType.ChannelPointRedemption) if (isReward && rewardsHighlight != null) { add(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor)) @@ -405,6 +416,7 @@ class HighlightsRepository( 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), 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 815e3c510..8385e337f 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 @@ -20,6 +20,7 @@ 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 kotlinx.coroutines.CoroutineScope @@ -52,7 +53,11 @@ class IgnoresRepository( 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() @@ -222,6 +227,10 @@ class IgnoresRepository( return null } + if (isViewerMilestone && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.WatchStreak)) { + return null + } + return copy( childMessage = childMessage?.applyIgnores(), ) @@ -368,11 +377,20 @@ class IgnoresRepository( 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 = ""), + 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/twitch/message/HighlightState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/HighlightState.kt index bdd5cbbcc..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 @@ -23,6 +23,7 @@ enum class HighlightType( ) { Subscription(HighlightPriority.HIGH), Announcement(HighlightPriority.HIGH), + WatchStreak(HighlightPriority.HIGH), ChannelPointRedemption(HighlightPriority.HIGH), FirstMessage(HighlightPriority.MEDIUM), ElevatedMessage(HighlightPriority.MEDIUM), 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 6d0f2bc5f..a31bbe971 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 @@ -95,11 +95,14 @@ 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"].isNullOrEmpty() 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 fc84795eb..b96104a0e 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 @@ -27,6 +27,7 @@ data class UserNoticeMessage( "bitsbadgetier", "ritual", "announcement", + "viewermilestone", ) fun parseUserNotice( @@ -98,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/preferences/notifications/highlights/HighlightItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightItem.kt index 5e70f3cf5..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 @@ -26,6 +26,7 @@ data class MessageHighlightItem( Username, Subscription, Announcement, + WatchStreak, ChannelPointRedemption, FirstMessage, ElevatedMessage, @@ -97,6 +98,7 @@ fun MessageHighlightItem.Type.toEntityType(): MessageHighlightEntityType = when 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 @@ -108,6 +110,7 @@ fun MessageHighlightEntityType.toItemType(): MessageHighlightItem.Type = when (t 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 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 aec971c02..e67a55e9f 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 @@ -383,6 +383,7 @@ private fun MessageHighlightItem( 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 @@ -474,6 +475,7 @@ private fun MessageHighlightItem( 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) 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 a518111a5..0f9adb8c0 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,6 +24,7 @@ data class MessageIgnoreItem( enum class Type { Subscription, Announcement, + WatchStreak, ChannelPointRedemption, FirstMessage, ElevatedMessage, @@ -74,6 +75,7 @@ fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( fun MessageIgnoreItem.Type.toEntityType(): MessageIgnoreEntityType = when (this) { 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 @@ -83,6 +85,7 @@ fun MessageIgnoreItem.Type.toEntityType(): MessageIgnoreEntityType = when (this) fun MessageIgnoreEntityType.toItemType(): MessageIgnoreItem.Type = when (this) { 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 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 2b34ba93f..fd4cd3dd2 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 @@ -382,9 +382,10 @@ private fun MessageIgnoreItem( 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.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 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 index ed340b814..1eff7c054 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -314,14 +314,15 @@ class ChatMessageMapper( isAlternateBackground: Boolean, textAlpha: Float, ): ChatMessageUiState.UserNoticeMessageUi { - val shouldHighlight = - highlights.any { + val highlightType = + highlights.firstOrNull { it.type == HighlightType.Subscription || - it.type == HighlightType.Announcement + it.type == HighlightType.Announcement || + it.type == HighlightType.WatchStreak } val backgroundColors = when { - shouldHighlight -> getHighlightColors(HighlightType.Subscription) + highlightType != null -> highlights.toBackgroundColors() else -> calculateCheckeredBackgroundColors(isAlternateBackground, false) } val timestamp = @@ -345,11 +346,11 @@ class ChatMessageMapper( lightBackgroundColor = backgroundColors.light, darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, - isHighlighted = shouldHighlight, + isHighlighted = highlightType != null, message = message, displayName = displayName, rawNameColor = rawNameColor, - shouldHighlight = shouldHighlight, + shouldHighlight = highlightType != null, ) } @@ -744,6 +745,13 @@ class ChatMessageMapper( ) } + HighlightType.WatchStreak -> { + BackgroundColors( + light = COLOR_WATCH_STREAK_HIGHLIGHT_LIGHT, + dark = COLOR_WATCH_STREAK_HIGHLIGHT_DARK, + ) + } + HighlightType.ChannelPointRedemption -> { BackgroundColors( light = COLOR_REDEMPTION_HIGHLIGHT_LIGHT, @@ -799,6 +807,7 @@ class ChatMessageMapper( 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) @@ -806,12 +815,14 @@ class ChatMessageMapper( 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 @@ -831,6 +842,8 @@ class ChatMessageMapper( 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) 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 index b70a3ead9..4d6aa0d61 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -219,9 +219,9 @@ fun ChatScreen( }, ) { index, message -> // reverseLayout=true: index 0 = bottom (newest), index+1 = visually above - val highlightedBelow = reversedMessages.getOrNull(index - 1)?.isHighlighted == true - val highlightedAbove = reversedMessages.getOrNull(index + 1)?.isHighlighted == true - val highlightShape = message.highlightShape(highlightedAbove, highlightedBelow, showLineSeparator) + val below = reversedMessages.getOrNull(index - 1) + val above = reversedMessages.getOrNull(index + 1) + val highlightShape = message.highlightShape(above, below, showLineSeparator) ChatMessageItem( message = message, highlightShape = highlightShape, @@ -639,14 +639,16 @@ private fun getFabMenuItem( private val HIGHLIGHT_CORNER_RADIUS = 8.dp private fun ChatMessageUiState.highlightShape( - highlightedAbove: Boolean, - highlightedBelow: Boolean, + above: ChatMessageUiState?, + below: ChatMessageUiState?, showLineSeparator: Boolean, ): Shape { if (!isHighlighted) return RectangleShape if (showLineSeparator) return RectangleShape - val top = if (highlightedAbove) 0.dp else HIGHLIGHT_CORNER_RADIUS - val bottom = if (highlightedBelow) 0.dp else HIGHLIGHT_CORNER_RADIUS + val sameAbove = above != null && above.lightBackgroundColor == lightBackgroundColor && above.darkBackgroundColor == darkBackgroundColor + val sameBelow = below != null && below.lightBackgroundColor == lightBackgroundColor && below.darkBackgroundColor == darkBackgroundColor + val top = if (sameAbove) 0.dp else HIGHLIGHT_CORNER_RADIUS + val bottom = if (sameBelow) 0.dp else HIGHLIGHT_CORNER_RADIUS return RoundedCornerShape(topStart = top, topEnd = top, bottomStart = bottom, bottomEnd = bottom) } 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 2e55c87c0..d61e4b272 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 @@ -573,6 +573,7 @@ 您的使用者名稱 訂閱與活動 公告 + 連續觀看 第一則訊息 固定訊息顯示 使用頻道點數兌換的醒目訊息 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index fe5cea35c..68acfbdef 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -436,6 +436,7 @@ Ваша імя карыстальніка Падпіскі і падзеі Аб\'явы + Серыі праглядаў Першыя паведамленні Павышаныя паведамленні Выдзяленні, абмененыя за балы канала diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index e0b2ab7a0..505f020df 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -443,6 +443,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader El vostre nom d\'usuari Subscripcions i esdeveniments Anuncis + Ratxes de visualització Primers missatges Missatges destacats Missatges destacats amb punts de canal diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 40ca5245e..61ed86a8b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -443,6 +443,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 diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index fef0fb4a8..2039a927b 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -435,6 +435,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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 3691d8448..93ccbb959 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -636,6 +636,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Your username Subscriptions and Events Announcements + Watch Streaks First Messages Elevated Messages Highlights redeemed with Channel Points diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index f15d9d3e9..dbbd62e6f 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -637,6 +637,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Your username Subscriptions and Events Announcements + Watch Streaks First Messages Elevated Messages Highlights redeemed with Channel Points diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index f036ac9ec..5362d1678 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -428,6 +428,7 @@ Your username Subscriptions and Events Announcements + Watch Streaks First Messages Elevated Messages Highlights redeemed with Channel Points diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index ffea5dc45..e7bec7ad3 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -439,6 +439,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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 97eaf480f..5450af080 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -435,6 +435,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Käyttäjänimesi Tilaukset ja tapahtumat Ilmoitukset + Katseluputket Ensimmäiset viestit Korostetut viestit Kanavapisteiden korostukset diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 77399643b..22a6fabff 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -436,6 +436,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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index cc4b78844..9b8694397 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -426,6 +426,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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8451c4710..53317dc67 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -430,6 +430,7 @@ Il tuo nome utente Iscrizioni ed Eventi Annunci + Serie di visualizzazione Primi Messaggi Messaggi Elevati Evidenziazioni riscattate con Punti Canale diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index ca8b614de..b12a89fa0 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -420,6 +420,7 @@ あなたのユーザー名 サブスクリプションとイベント お知らせ + 視聴ストリーク 最初のメッセージ メッセージのピン留め表示 チャンネルポイントで交換されたメッセージのハイライト diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 657337e5d..cdc29e232 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -571,6 +571,7 @@ Сіздің пайдаланушы атыңыз Жазылымдар мен оқиғалар Хабарландырулар + Көру сериялары Алғашқы хабарлар Көтерілген хабарлар Арна ұпайларымен сатып алынған ерекшелеулер diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 64e69b4dd..faa839d9d 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -571,6 +571,7 @@ ଆପଣଙ୍କର ଉପଯୋଗକର୍ତ୍ତା ନାମ ସଦସ୍ୟତା ଏବଂ ଘଟଣା | ଘୋଷଣା + ଦେଖିବା ଧାରା ପ୍ରଥମ ବାର୍ତ୍ତା | ଉଚ୍ଚତର ବାର୍ତ୍ତା | ଚ୍ୟାନେଲ ପଏଣ୍ଟ ସହିତ ମୁକ୍ତ ହୋଇଥିବା ହାଇଲାଇଟ୍ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a4a49dd6f..ead06cbd4 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -442,6 +442,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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6ac02783e..4e3b4bc77 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -431,6 +431,7 @@ Seu Nome Inscrições e Eventos Avisos + Sequências de visualização Primeiras Mensagens Mensagens Elevadas Destaques resgatados com Pontos de Canal diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d48ab76fd..484a00635 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -431,6 +431,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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index e6f4644c7..fb8f7bf35 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -441,6 +441,7 @@ Ваше имя пользователя Подписки и События Анонсы + Серии просмотров Первые сообщения Возвышенные сообщения Выделения купленные за баллы канала diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index e5e365c5d..6a8b41440 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -534,6 +534,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Ваше корисничко име Претплате и догађаји Обавештења + Серије гледања Прве поруке Истакнуте поруке Истицања откупљена поенима канала diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 9edcb818f..bc1d649b6 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -434,6 +434,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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 691b46aa2..9fc14c5f2 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -443,6 +443,7 @@ Ваше ім’я користувача Підписки та події Оголошення + Серії переглядів Перші повідомлення Піднесені повідомлення Виділення куплені балами каналу diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f849bd18..a6c2f2a2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -677,6 +677,7 @@ Your username Subscriptions and Events Announcements + Watch Streaks First Messages Elevated Messages Highlights redeemed with Channel Points From 817c279a7843bfe3486fc3c9dc386a5c97fb0806 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 14:27:20 +0200 Subject: [PATCH 237/349] feat(chat): Add reward labels for gigantified emotes, animated messages, and hype chat details --- .../data/repo/chat/ChatEventProcessor.kt | 26 ++++++++++-- .../twitch/message/PointRedemptionMessage.kt | 7 ++-- .../data/twitch/message/PrivMessage.kt | 39 ++++++++++++++++- .../data/twitch/pubsub/PubSubConnection.kt | 3 +- .../dto/redemption/PointRedemptionReward.kt | 42 +++++++++++++++---- .../dankchat/ui/chat/ChatMessageMapper.kt | 31 +++++++++++--- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 6 ++- .../main/res/values-b+zh+Hant+TW/strings.xml | 4 ++ app/src/main/res/values-be-rBY/strings.xml | 4 ++ app/src/main/res/values-ca/strings.xml | 4 ++ app/src/main/res/values-cs/strings.xml | 4 ++ app/src/main/res/values-de-rDE/strings.xml | 4 ++ app/src/main/res/values-en-rAU/strings.xml | 4 ++ app/src/main/res/values-en-rGB/strings.xml | 4 ++ app/src/main/res/values-en/strings.xml | 4 ++ app/src/main/res/values-es-rES/strings.xml | 4 ++ app/src/main/res/values-fi-rFI/strings.xml | 4 ++ app/src/main/res/values-fr-rFR/strings.xml | 4 ++ app/src/main/res/values-hu-rHU/strings.xml | 4 ++ app/src/main/res/values-it/strings.xml | 4 ++ app/src/main/res/values-ja-rJP/strings.xml | 4 ++ app/src/main/res/values-kk-rKZ/strings.xml | 4 ++ app/src/main/res/values-or-rIN/strings.xml | 4 ++ app/src/main/res/values-pl-rPL/strings.xml | 4 ++ app/src/main/res/values-pt-rBR/strings.xml | 4 ++ app/src/main/res/values-pt-rPT/strings.xml | 4 ++ app/src/main/res/values-ru-rRU/strings.xml | 4 ++ app/src/main/res/values-sr/strings.xml | 4 ++ app/src/main/res/values-tr-rTR/strings.xml | 4 ++ app/src/main/res/values-uk-rUA/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 31 files changed, 229 insertions(+), 21 deletions(-) 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 index 72b20a4cb..7b7da059b 100644 --- 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 @@ -154,8 +154,11 @@ class ChatEventProcessor( return } - if (pubSubMessage.data.reward.requiresUserInput) { - val id = pubSubMessage.data.reward.id + // 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) -> { @@ -450,7 +453,7 @@ class ChatEventProcessor( }.getOrElse { Log.e(TAG, "Failed to parse message", it) return - } ?: return + }?.let { resolveAutomaticRewardCost(it) } ?: return if (message is NoticeMessage && usersRepository.isGlobalChannel(message.channel)) { chatMessageRepository.broadcastToAllChannels(ChatItem(message, importance = ChatImportance.SYSTEM)) @@ -534,6 +537,23 @@ class ChatEventProcessor( }.orEmpty() } + 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 cost = reward?.data?.reward?.effectiveCost ?: return message + return message.copy(rewardCost = cost) + } + private fun trackUserState(message: Message) { if (message !is PrivMessage) { return 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 e5ca4d58e..23e7528b8 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 @@ -32,11 +32,12 @@ data class PointRedemptionMessage( id = data.id, name = data.user.name, displayName = data.user.displayName, - title = data.reward.title, + title = data.reward.effectiveTitle, rewardImageUrl = data.reward.images?.imageLarge - ?: data.reward.defaultImages.imageLarge, - cost = data.reward.cost, + ?: data.reward.defaultImages?.imageLarge + ?: "", + 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 a31bbe971..fa0a90d3d 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 @@ -32,6 +32,7 @@ data class PrivMessage( val userDisplay: UserDisplay? = null, val thread: MessageThreadHeader? = null, val replyMentionOffset: Int = 0, + val rewardCost: Int? = null, override val emoteData: EmoteData = EmoteData( message = originalMessage, @@ -104,7 +105,19 @@ val PrivMessage.isViewerMilestone: Boolean get() = tags["msg-id"] == "viewermilestone" val PrivMessage.isReward: Boolean - get() = tags["msg-id"] == "highlighted-message" || !tags["custom-reward-id"].isNullOrEmpty() + 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" @@ -112,6 +125,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(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) 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 58d14e1e9..1e1e9b1a2 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 @@ -295,7 +295,7 @@ class PubSubConnection( } is PubSubTopic.PointRedemptions -> { - if (messageTopic != "reward-redeemed") { + if (messageTopic !in POINT_REDEMPTION_TOPICS) { return false } @@ -408,5 +408,6 @@ class PubSubConnection( private const val PING_PAYLOAD = "{\"type\":\"PING\"}" private const val PUBSUB_URL = "wss://pubsub-edge.twitch.tv" private val TAG = PubSubConnection::class.java.simpleName + private val POINT_REDEMPTION_TOPICS = setOf("reward-redeemed", "automatic-reward-redeemed") } } 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/ui/chat/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt index 1eff7c054..b85d6fdbb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -24,6 +24,10 @@ 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 @@ -533,11 +537,28 @@ class ChatMessageMapper( } val highlightHeader = - highlights.highestPriorityHighlight()?.let { - when (it.type) { - HighlightType.FirstMessage -> TextResource.Res(R.string.highlight_header_first_time_chat) - HighlightType.ElevatedMessage -> TextResource.Res(R.string.highlight_header_elevated_chat) - else -> null + when { + isGigantifiedEmote -> { + rewardCost?.let { TextResource.Res(R.string.highlight_header_gigantified_emote_cost, persistentListOf(it)) } + ?: TextResource.Res(R.string.highlight_header_gigantified_emote) + } + + isAnimatedMessage -> { + rewardCost?.let { TextResource.Res(R.string.highlight_header_animated_message_cost, persistentListOf(it)) } + ?: TextResource.Res(R.string.highlight_header_animated_message) + } + + isElevatedMessage -> { + hypeChatInfo?.let { TextResource.Plain(it) } + ?: TextResource.Res(R.string.highlight_header_elevated_chat) + } + + highlights.highestPriorityHighlight()?.type == HighlightType.FirstMessage -> { + TextResource.Res(R.string.highlight_header_first_time_chat) + } + + else -> { + null } } 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 index 4d6aa0d61..fa329c096 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -195,7 +195,10 @@ fun ChatScreen( LazyColumn( state = listState, reverseLayout = true, - contentPadding = contentPadding, + contentPadding = PaddingValues( + top = contentPadding.calculateTopPadding() + MESSAGE_GAP, + bottom = contentPadding.calculateBottomPadding() + MESSAGE_GAP, + ), modifier = Modifier .fillMaxSize() @@ -636,6 +639,7 @@ private fun getFabMenuItem( } } +private val MESSAGE_GAP = 4.dp private val HIGHLIGHT_CORNER_RADIUS = 8.dp private fun ChatMessageUiState.highlightShape( 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 d61e4b272..4c3236c0d 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 @@ -85,6 +85,10 @@ 首次聊天 固定聊天訊息 + 巨大表情 + 巨大表情 · %1$d Bits + 動畫訊息 + 動畫訊息 · %1$d Bits %1$d 秒 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 68acfbdef..950288ae9 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -80,6 +80,10 @@ Першае паведамленне Павышанае паведамленне + Гіганцкі эмоут + Гіганцкі эмоут · %1$d Bits + Аніміраванае паведамленне + Аніміраванае паведамленне · %1$d Bits %1$d секунду %1$d секунды diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 505f020df..abe3d662a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -83,6 +83,10 @@ Primer missatge Missatge destacat + Emote gegant + Emote gegant · %1$d Bits + Missatge animat + Missatge animat · %1$d Bits %1$d segon %1$d segons diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 61ed86a8b..bbe8b6376 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -80,6 +80,10 @@ První zpráva Zvýrazněná zpráva + Obří emote + Obří emote · %1$d Bits + Animovaná zpráva + Animovaná zpráva · %1$d Bits %1$d sekundu %1$d sekundy diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 2039a927b..00fce93f9 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -80,6 +80,10 @@ Erste Nachricht Hervorgehobene Nachricht + Riesenemote + Riesenemote · %1$d Bits + Animierte Nachricht + Animierte Nachricht · %1$d Bits %1$d Sekunde %1$d Sekunden diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 93ccbb959..12ed0ea1f 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -76,6 +76,10 @@ First Time Chat Elevated Chat + Gigantified Emote + Gigantified Emote · %1$d Bits + Animated Message + Animated Message · %1$d Bits %1$d second %1$d seconds diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index dbbd62e6f..7c7a8ce79 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -76,6 +76,10 @@ First Time Chat Elevated Chat + Gigantified Emote + Gigantified Emote · %1$d Bits + Animated Message + Animated Message · %1$d Bits %1$d second %1$d seconds diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 5362d1678..7f28a1c72 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -80,6 +80,10 @@ First Time Chat Elevated Chat + Gigantified Emote + Gigantified Emote · %1$d Bits + Animated Message + Animated Message · %1$d Bits %1$d second %1$d seconds diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index e7bec7ad3..647e11e3b 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -79,6 +79,10 @@ Primer mensaje Mensaje destacado + Emote gigante + Emote gigante · %1$d Bits + Mensaje animado + Mensaje animado · %1$d Bits %1$d segundo %1$d segundos diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 5450af080..d5a66eee2 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -82,6 +82,10 @@ Ensimmäinen viesti Korostettu viesti + Jättimäinen emote + Jättimäinen emote · %1$d Bits + Animoitu viesti + Animoitu viesti · %1$d Bits %1$d sekunti %1$d sekuntia diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 22a6fabff..e36ed9e57 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -80,6 +80,10 @@ Premier message Message mis en avant + Emote géant + Emote géant · %1$d Bits + Message animé + Message animé · %1$d Bits %1$d seconde %1$d secondes diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 9b8694397..517feb516 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -80,6 +80,10 @@ Első üzenet Kiemelt üzenet + Óriás emote + Óriás emote · %1$d Bits + Animált üzenet + Animált üzenet · %1$d Bits %1$d másodperc %1$d másodperc diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 53317dc67..63c4467b1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -79,6 +79,10 @@ Primo messaggio Messaggio elevato + Emote gigante + Emote gigante · %1$d Bits + Messaggio animato + Messaggio animato · %1$d Bits %1$d secondo %1$d secondi diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index b12a89fa0..b8a37cf4e 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -79,6 +79,10 @@ 初めてのチャット ピン留めチャット + 巨大エモート + 巨大エモート · %1$d Bits + アニメーションメッセージ + アニメーションメッセージ · %1$d Bits %1$d秒 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index cdc29e232..98d2f9791 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -84,6 +84,10 @@ %1$s (%2$s) Алғашқы рет чат Көтерілген чат + Алып эмоут + Алып эмоут · %1$d Bits + Анимациялық хабарлама + Анимациялық хабарлама · %1$d Bits %1$d секунд %1$d секунд diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index faa839d9d..e0f4b2d14 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -84,6 +84,10 @@ %1$s (%2$s) ପ୍ରଥମ ଥର ଚାଟ୍ ଉଚ୍ଚତର ଚାଟ୍ + ବିଶାଳ ଇମୋଟ + ବିଶାଳ ଇମୋଟ · %1$d Bits + ଆନିମେଟେଡ ବାର୍ତ୍ତା + ଆନିମେଟେଡ ବାର୍ତ୍ତା · %1$d Bits %1$d ସେକେଣ୍ଡ %1$d ସେକେଣ୍ଡ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index ead06cbd4..e21759bc9 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -79,6 +79,10 @@ Pierwsza wiadomość Podwyższony czat + Gigantyczny emote + Gigantyczny emote · %1$d Bits + Animowana wiadomość + Animowana wiadomość · %1$d Bits %1$d sekundę %1$d sekundy diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4e3b4bc77..de3809ac8 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -80,6 +80,10 @@ Primeira mensagem Mensagem elevada + Emote gigante + Emote gigante · %1$d Bits + Mensagem animada + Mensagem animada · %1$d Bits %1$d segundo %1$d segundos diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 484a00635..76fa6945e 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -80,6 +80,10 @@ Primeira mensagem Mensagem elevada + Emote gigante + Emote gigante · %1$d Bits + Mensagem animada + Mensagem animada · %1$d Bits %1$d segundo %1$d segundos diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index fb8f7bf35..a5b3adf3d 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -80,6 +80,10 @@ Первое сообщение Выделенное сообщение + Гигантский эмоут + Гигантский эмоут · %1$d Bits + Анимированное сообщение + Анимированное сообщение · %1$d Bits %1$d секунду %1$d секунды diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 6a8b41440..8ed63e9da 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -84,6 +84,10 @@ Прва порука Истакнута порука + Гигантски емоут + Гигантски емоут · %1$d Bits + Анимирана порука + Анимирана порука · %1$d Bits %1$d секунду %1$d секунде diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index bc1d649b6..e03e1dec0 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -79,6 +79,10 @@ İlk mesaj Yükseltilmiş mesaj + Dev Emote + Dev Emote · %1$d Bits + Animasyonlu Mesaj + Animasyonlu Mesaj · %1$d Bits %1$d saniye %1$d saniye diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 9fc14c5f2..9540dc99a 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -80,6 +80,10 @@ Перше повідомлення Піднесене повідомлення + Гігантський емоут + Гігантський емоут · %1$d Bits + Анімоване повідомлення + Анімоване повідомлення · %1$d Bits %1$d секунду %1$d секунди diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6c2f2a2a..182a97b42 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,6 +86,10 @@ First Time Chat Elevated Chat + Gigantified Emote + Gigantified Emote · %1$d Bits + Animated Message + Animated Message · %1$d Bits %1$d second From 62edd606022eb8e233284c0ea8189918906c9f54 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 15:17:10 +0200 Subject: [PATCH 238/349] fix(theme): Allow palette style for system accent, fix AMOLED using real system colors --- .../appearance/AppearanceSettings.kt | 3 +- .../appearance/AppearanceSettingsScreen.kt | 11 ++++--- .../flxrs/dankchat/ui/theme/DankChatTheme.kt | 30 +++++++++++++------ .../main/res/values-b+zh+Hant+TW/strings.xml | 2 ++ app/src/main/res/values-be-rBY/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de-rDE/strings.xml | 2 ++ app/src/main/res/values-en-rAU/strings.xml | 2 ++ app/src/main/res/values-en-rGB/strings.xml | 2 ++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-fi-rFI/strings.xml | 2 ++ app/src/main/res/values-fr-rFR/strings.xml | 2 ++ app/src/main/res/values-hu-rHU/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja-rJP/strings.xml | 2 ++ app/src/main/res/values-kk-rKZ/strings.xml | 2 ++ app/src/main/res/values-or-rIN/strings.xml | 2 ++ app/src/main/res/values-pl-rPL/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt-rPT/strings.xml | 2 ++ app/src/main/res/values-ru-rRU/strings.xml | 2 ++ app/src/main/res/values-sr/strings.xml | 2 ++ app/src/main/res/values-tr-rTR/strings.xml | 2 ++ app/src/main/res/values-uk-rUA/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 27 files changed, 78 insertions(+), 14 deletions(-) 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 b0382542f..17aed96ad 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 @@ -22,7 +22,7 @@ data class AppearanceSettings( val theme: ThemePreference = ThemePreference.System, val trueDarkTheme: Boolean = false, val accentColor: AccentColor? = null, - val paletteStyle: PaletteStylePreference = PaletteStylePreference.TonalSpot, + val paletteStyle: PaletteStylePreference = PaletteStylePreference.SystemDefault, val fontSize: Int = 14, val keepScreenOn: Boolean = true, val lineSeparator: Boolean = false, @@ -51,6 +51,7 @@ enum class PaletteStylePreference( @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), 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 index b08cc89ae..1b2339e73 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -268,7 +268,7 @@ private fun ThemeCategory( ) PaletteStyleDialog( paletteStyle = paletteStyle, - isEnabled = hasCustomAccent, + showSystemDefault = !hasCustomAccent, onChange = { scope.launch { onInteraction(AppearanceSettingsInteraction.SetPaletteStyle(it)) } }, ) SwitchPreferenceItem( @@ -439,17 +439,20 @@ private fun AccentColorCircle( @Composable private fun PaletteStyleDialog( paletteStyle: PaletteStylePreference, - isEnabled: Boolean, + showSystemDefault: Boolean, onChange: (PaletteStylePreference) -> Unit, ) { val scope = rememberCoroutineScope() ExpandablePreferenceItem( title = stringResource(R.string.preference_palette_style_title), summary = stringResource(paletteStyle.labelRes), - isEnabled = isEnabled, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val standardStyles = remember { PaletteStylePreference.entries.filter { it.isStandard } } + 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) } 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 index f1017964c..f8acd7ca0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -58,9 +58,13 @@ fun DankChatTheme(content: @Composable () -> Unit) { 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 { - accentColor != null -> rememberDynamicColorScheme( - seedColor = accentColor.seedColor, + seedColor != null && !useSystemColors -> rememberDynamicColorScheme( + seedColor = seedColor, isDark = false, style = paletteStyle, ) @@ -71,18 +75,25 @@ fun DankChatTheme(content: @Composable () -> Unit) { } val darkColorScheme = when { - accentColor != null -> rememberDynamicColorScheme( - seedColor = accentColor.seedColor, + seedColor != null && !useSystemColors -> rememberDynamicColorScheme( + seedColor = seedColor, isDark = true, isAmoled = trueDarkTheme, style = paletteStyle, ) - dynamicColor && trueDarkTheme -> rememberDynamicColorScheme( - seedColor = dynamicDarkColorScheme(LocalContext.current).primary, - isDark = true, - isAmoled = true, - 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) @@ -108,6 +119,7 @@ fun DankChatTheme(content: @Composable () -> Unit) { } private fun PaletteStylePreference.toPaletteStyle(): PaletteStyle = when (this) { + PaletteStylePreference.SystemDefault -> PaletteStyle.TonalSpot PaletteStylePreference.TonalSpot -> PaletteStyle.TonalSpot PaletteStylePreference.Neutral -> PaletteStyle.Neutral PaletteStylePreference.Vibrant -> PaletteStyle.Vibrant 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 4c3236c0d..48c8dc21d 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 @@ -412,6 +412,8 @@ 棕色 灰色 色彩風格 + 系統預設 + 使用系統預設色彩配置 Tonal Spot 沉穩柔和的色調 Neutral diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 950288ae9..09a94dfcc 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -277,6 +277,8 @@ Карычневы Шэры Стыль колераў + Сістэмны па змаўчанні + Выкарыстоўваць стандартную колеравую палітру сістэмы Tonal Spot Спакойныя і прыглушаныя колеры Neutral diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index abe3d662a..6a219fb87 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -282,6 +282,8 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Marró Gris Estil de color + Predeterminat del sistema + Utilitza la paleta de colors predeterminada del sistema Tonal Spot Colors calmats i suaus Neutral diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bbe8b6376..28759df13 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -281,6 +281,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Hnědá Šedá Styl barev + Výchozí systémový + Použít výchozí systémovou paletu barev Tonal Spot Klidné a tlumené barvy Neutral diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 00fce93f9..d2e6a561f 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -271,6 +271,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Braun Grau Farbstil + Systemstandard + Standard-Farbpalette des Systems verwenden Tonal Spot Ruhige und gedämpfte Farben Neutral diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 12ed0ea1f..783e15367 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -237,6 +237,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Brown Grey Colour style + System default + Use the default system colour palette Tonal Spot Calm and subdued colours Neutral diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 7c7a8ce79..943fc3654 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -237,6 +237,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Brown Grey Colour style + System default + Use the default system colour palette Tonal Spot Calm and subdued colours Neutral diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7f28a1c72..fbc9dc33b 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -264,6 +264,8 @@ Brown Gray Color style + System default + Use the default system color palette Tonal Spot Calm and subdued colors Neutral diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 647e11e3b..c07e3864e 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -275,6 +275,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Marrón Gris Estilo de color + Predeterminado del sistema + Usar la paleta de colores predeterminada del sistema Tonal Spot Colores tranquilos y tenues Neutral diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index d5a66eee2..954585d1f 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -271,6 +271,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Ruskea Harmaa Värien tyyli + Järjestelmän oletus + Käytä järjestelmän oletusväripalettia Tonal Spot Rauhalliset ja hillityt värit Neutral diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index e36ed9e57..29edebd78 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -274,6 +274,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 517feb516..9775837c5 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -264,6 +264,8 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 63c4467b1..13958b0ef 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -268,6 +268,8 @@ Marrone Grigio Stile colore + Predefinito di sistema + Usa la tavolozza colori predefinita del sistema Tonal Spot Colori calmi e tenui Neutral diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index b8a37cf4e..922bc336f 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -258,6 +258,8 @@ グレー カラースタイル + システムデフォルト + システムのデフォルトカラーパレットを使用 Tonal Spot 落ち着いた控えめな色合い Neutral diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 98d2f9791..ce25885ae 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -410,6 +410,8 @@ Қоңыр Сұр Түс стилі + Жүйе әдепкісі + Жүйенің әдепкі түс палитрасын пайдалану Tonal Spot Тыныш және бәсең түстер Neutral diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index e0f4b2d14..265264900 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -410,6 +410,8 @@ ବାଦାମୀ ଧୂସର ରଙ୍ଗ ଶୈଳୀ + ସିଷ୍ଟମ ଡିଫଲ୍ଟ + ସିଷ୍ଟମର ଡିଫଲ୍ଟ ରଙ୍ଗ ପ୍ୟାଲେଟ ବ୍ୟବହାର କରନ୍ତୁ Tonal Spot ଶାନ୍ତ ଏବଂ ମୃଦୁ ରଙ୍ଗ Neutral diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index e21759bc9..5b1a358ae 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -278,6 +278,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Brązowy Szary Styl kolorów + Domyślny systemowy + Użyj domyślnej palety kolorów systemu Tonal Spot Spokojne i stonowane kolory Neutral diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index de3809ac8..75ba0a2ec 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -269,6 +269,8 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 76fa6945e..1542fa5c7 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -269,6 +269,8 @@ Castanho Cinzento Estilo de cor + Predefinição do sistema + Usar a paleta de cores predefinida do sistema Tonal Spot Cores calmas e suaves Neutral diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index a5b3adf3d..3d8be8090 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -279,6 +279,8 @@ Коричневый Серый Стиль цвета + Системный по умолчанию + Использовать стандартную цветовую палитру системы Tonal Spot Спокойные и приглушённые цвета Neutral diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 8ed63e9da..ef7e53b89 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -372,6 +372,8 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Braon Siva Stil boja + Подразумевано системско + Користи подразумевану системску палету боја Tonal Spot Mirne i prigušene boje Neutral diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e03e1dec0..a66ea4af5 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -270,6 +270,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Kahverengi Gri Renk stili + Sistem varsayılanı + Varsayılan sistem renk paletini kullan Tonal Spot Sakin ve yumuşak renkler Neutral diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 9540dc99a..414f251ad 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -281,6 +281,8 @@ Коричневий Сірий Стиль кольору + Системний за замовчуванням + Використовувати стандартну кольорову палітру системи Tonal Spot Спокійні та приглушені кольори Neutral diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 182a97b42..32fdd19aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,6 +456,8 @@ Brown Gray Color style + System default + Use the default system color palette Tonal Spot Calm and subdued colors Neutral From f953280745638509a06543ec4fed58c9ad8b4b38 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 15:39:05 +0200 Subject: [PATCH 239/349] fix(chat): Fix link tap overlap and long-press by using string annotations instead of LinkAnnotation --- .../dankchat/ui/chat/messages/PrivMessage.kt | 32 ++++++++----------- .../ui/chat/messages/WhisperAndRedemption.kt | 32 ++++++++----------- .../ui/chat/messages/common/Linkification.kt | 11 ++----- 3 files changed, 29 insertions(+), 46 deletions(-) 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 index ca249aa7e..3a429e98f 100644 --- 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 @@ -279,31 +279,25 @@ private fun PrivMessageText( interactionSource = interactionSource, onEmoteClick = onEmoteClick, onTextClick = { offset -> - annotatedString - .getStringAnnotations("USER", offset, offset) - .firstOrNull() - ?.let { annotation -> - parseUserAnnotation(annotation.item)?.let { user -> - onUserClick(user.userId, user.userName, user.displayName, user.channel.orEmpty(), message.badges, false) - } - } + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + val url = annotatedString.getStringAnnotations("URL", offset, offset).firstOrNull() - annotatedString - .getStringAnnotations("URL", offset, offset) - .firstOrNull() - ?.let { annotation -> - launchCustomTab(context, annotation.item) + 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() - ?.let { parseUserAnnotation(it.item) } + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() when { - user != null -> onUserClick(user.userId, user.userName, user.displayName, user.channel.orEmpty(), message.badges, true) + 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/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt index 6077ffb1c..8227192a0 100644 --- 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 @@ -209,31 +209,25 @@ private fun WhisperMessageText( animateGifs = animateGifs, onEmoteClick = onEmoteClick, onTextClick = { offset -> - annotatedString - .getStringAnnotations("USER", offset, offset) - .firstOrNull() - ?.let { annotation -> - parseUserAnnotation(annotation.item)?.let { user -> - onUserClick(user.userId, user.userName, user.displayName, message.badges, false) - } - } + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + val url = annotatedString.getStringAnnotations("URL", offset, offset).firstOrNull() - annotatedString - .getStringAnnotations("URL", offset, offset) - .firstOrNull() - ?.let { annotation -> - launchCustomTab(context, annotation.item) + 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() - ?.let { parseUserAnnotation(it.item) } + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() when { - user != null -> onUserClick(user.userId, user.userName, user.displayName, message.badges, true) + user != null -> parseUserAnnotation(user.item)?.let { + onUserClick(it.userId, it.userName, it.displayName, message.badges, true) + } + else -> onMessageLongClick(message.id, message.fullMessage) } }, 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 index 9106083cc..63f88d175 100644 --- 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 @@ -3,9 +3,7 @@ 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.LinkAnnotation import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle @@ -18,10 +16,6 @@ fun AnnotatedString.Builder.appendWithLinks( ) { val matcher = Patterns.WEB_URL.matcher(text) var lastIndex = 0 - val linkStyles = - TextLinkStyles( - style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), - ) while (matcher.find()) { val start = matcher.start() @@ -53,8 +47,7 @@ fun AnnotatedString.Builder.appendWithLinks( append(text.substring(lastIndex, start)) } - val link = LinkAnnotation.Url(url = url, styles = linkStyles) - pushLink(link) + pushStringAnnotation(tag = URL_ANNOTATION_TAG, annotation = url) withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { append(rawUrl) } @@ -67,3 +60,5 @@ fun AnnotatedString.Builder.appendWithLinks( append(text.substring(lastIndex)) } } + +const val URL_ANNOTATION_TAG = "URL" From 5f964baea3979f3764b6f0c399ea737aea18db85 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 16:07:12 +0200 Subject: [PATCH 240/349] chore: Bump version to 4.0.4 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c57c9fcff..b7acf6933 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40003 - versionName = "4.0.3" + versionCode = 40004 + versionName = "4.0.4" } androidResources { generateLocaleConfig = true } From a9ffcf6d19808c51cef144af9fa842dea6be973a Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 19:16:03 +0200 Subject: [PATCH 241/349] fix(theme): Hoist NavController above DankChatTheme to prevent state reset on color changes --- .../main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt | 4 +--- .../main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) 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 index 3f976c744..68e4c5a8a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -168,8 +168,8 @@ class MainActivity : ComponentActivity() { private fun setupComposeUi() { setContent { + val navController = rememberNavController() DankChatTheme { - val navController = rememberNavController() val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle( initialValue = dankChatPreferenceStore.isLoggedIn, ) @@ -456,7 +456,6 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() - if (!isChangingConfigurations && !isInSupportedPictureInPictureMode) { handleShutDown() } @@ -465,7 +464,6 @@ class MainActivity : ComponentActivity() { @SuppressLint("InlinedApi") override fun onStart() { super.onStart() - val hasCompletedOnboarding = onboardingDataStore.current().hasCompletedOnboarding val needsNotificationPermission = hasCompletedOnboarding && isAtLeastTiramisu && !hasPermission(Manifest.permission.POST_NOTIFICATIONS) when { 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 index f8acd7ca0..3daefb58d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -107,9 +107,9 @@ fun DankChatTheme(content: @Composable () -> Unit) { onSurfaceDark = darkColorScheme.onSurface, ) val colors = if (darkTheme) darkColorScheme else lightColorScheme - + val motionScheme = remember { MotionScheme.expressive() } MaterialExpressiveTheme( - motionScheme = MotionScheme.expressive(), + motionScheme = motionScheme, colorScheme = colors, ) { CompositionLocalProvider(LocalAdaptiveColors provides adaptiveColors) { From c1a79aba86c0870dff50ecab6b6766b7b3fc4d46 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 19:22:31 +0200 Subject: [PATCH 242/349] fix(suggestions): Increase suggestion item padding to 5dp --- .../com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 64765a8f5..8c628c899 100644 --- 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 @@ -94,7 +94,7 @@ fun SuggestionDropdown( ) { LazyColumn( state = listState, - contentPadding = PaddingValues(vertical = 4.dp), + contentPadding = PaddingValues(vertical = 5.dp), modifier = Modifier .fillMaxWidth() @@ -134,7 +134,7 @@ private fun SuggestionItem( modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 4.dp), + .padding(horizontal = 16.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, ) { when (suggestion) { From 698d258aa54bba55c7c4f83f0e59e6903f7c5597 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 19:31:17 +0200 Subject: [PATCH 243/349] fix(input): Disable swipe-to-hide in sheets, restore input when opening reply/mention sheets --- .../kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 index 9797c4e53..0c342bb61 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -297,6 +297,15 @@ fun MainScreen( 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 = @@ -914,7 +923,7 @@ private fun BoxScope.WideSplitLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = showInput, + enabled = showInput && !isSheetOpen, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), @@ -1101,7 +1110,7 @@ private fun BoxScope.NormalStackedLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = showInput, + enabled = showInput && !isSheetOpen, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), From a5cf900468d48f7bfc29e1aa8f6c90d328a5e8a0 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 20:37:49 +0200 Subject: [PATCH 244/349] chore: Bump version to 4.0.5 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7acf6933..26b441a49 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40004 - versionName = "4.0.4" + versionCode = 40005 + versionName = "4.0.5" } androidResources { generateLocaleConfig = true } From c2a8e088554686aafc4e76b4f189a836a36d8407 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 1 Apr 2026 22:17:51 +0200 Subject: [PATCH 245/349] fix(whispers): Fix notifications, bell icon, unread indicator, remove dead PubSub whisper code --- .../dankchat/data/repo/chat/ChatConnector.kt | 2 -- .../data/repo/chat/ChatEventProcessor.kt | 33 +++-------------- .../dankchat/data/repo/chat/ChatRepository.kt | 6 +--- .../data/twitch/message/WhisperMessage.kt | 36 ------------------- .../data/twitch/pubsub/PubSubConnection.kt | 14 -------- .../data/twitch/pubsub/PubSubManager.kt | 6 ---- .../data/twitch/pubsub/PubSubMessage.kt | 5 --- .../data/twitch/pubsub/PubSubTopic.kt | 4 --- .../pubsub/dto/PubSubDataObjectMessage.kt | 12 ------- .../twitch/pubsub/dto/whisper/WhisperData.kt | 17 --------- .../pubsub/dto/whisper/WhisperDataBadge.kt | 11 ------ .../pubsub/dto/whisper/WhisperDataEmote.kt | 13 ------- .../dto/whisper/WhisperDataRecipient.kt | 17 --------- .../pubsub/dto/whisper/WhisperDataTags.kt | 17 --------- .../ui/chat/mention/MentionViewModel.kt | 22 +++++++++++- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 3 +- .../ui/main/MainScreenEventHandler.kt | 13 +++++-- .../ui/main/channel/ChannelTabUiState.kt | 1 + .../ui/main/channel/ChannelTabViewModel.kt | 2 ++ .../dankchat/ui/main/sheet/MentionSheet.kt | 13 +++++++ 20 files changed, 55 insertions(+), 192 deletions(-) delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataObjectMessage.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperData.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataBadge.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataEmote.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataRecipient.kt delete mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataTags.kt 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 index fc138f48d..1b2025a0a 100644 --- 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 @@ -98,8 +98,6 @@ class ChatConnector( writeConnection.sendMessage(message) } - val connectedAndHasWhisperTopic: Boolean get() = pubSubManager.connectedAndHasWhisperTopic - 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 index 7b7da059b..48d92d92b 100644 --- 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 @@ -130,7 +130,6 @@ class ChatEventProcessor( chatConnector.pubSubEvents.collect { pubSubMessage -> when (pubSubMessage) { is PubSubMessage.PointRedemption -> handlePubSubReward(pubSubMessage) - is PubSubMessage.Whisper -> handlePubSubWhisper(pubSubMessage) is PubSubMessage.ModeratorAction -> handlePubSubModeration(pubSubMessage) } } @@ -184,29 +183,6 @@ class ChatEventProcessor( } } - private suspend fun handlePubSubWhisper(pubSubMessage: PubSubMessage.Whisper) { - if (messageProcessor.isUserBlocked(pubSubMessage.data.userId)) { - return - } - - val message = - runCatching { - messageProcessor.processWhisper(WhisperMessage.fromPubSub(pubSubMessage.data)) as? WhisperMessage - }.getOrNull() ?: return - - val item = ChatItem(message, isMentionTab = true) - chatNotificationRepository.addWhisper(item) - - if (pubSubMessage.data.userId == userStateRepository.userState.value.userId) { - return - } - - val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() - usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) - chatNotificationRepository.incrementMentionCount(WhisperMessage.WHISPER_CHANNEL, 1) - chatNotificationRepository.emitMessages(listOf(item)) - } - private fun handlePubSubModeration(pubSubMessage: PubSubMessage.ModeratorAction) { val (timestamp, channelId, data) = pubSubMessage val channelName = channelRepository.tryGetUserNameById(channelId) ?: return @@ -399,10 +375,6 @@ class ChatEventProcessor( } private suspend fun handleWhisper(ircMessage: IrcMessage) { - if (chatConnector.connectedAndHasWhisperTopic) { - return - } - val userId = ircMessage.tags["user-id"]?.toUserId() if (messageProcessor.isUserBlocked(userId)) { return @@ -419,10 +391,15 @@ class ChatEventProcessor( 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) { 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 28f5ca286..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 @@ -85,10 +85,6 @@ class ChatRepository( } fun fakeWhisperIfNecessary(input: String) { - if (chatConnector.connectedAndHasWhisperTopic) { - return - } - 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) @@ -103,7 +99,7 @@ class ChatRepository( displayName = displayName, color = userState.color?.let(Color::parseColor), recipientId = null, - recipientColor = null, + recipientColor = usersRepository.getCachedUserColor(split[1].toUserName()), recipientName = split[1].toUserName(), recipientDisplayName = split[1].toDisplayName(), message = message, 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 6347154f7..1292136a8 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 @@ -10,7 +10,6 @@ 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 java.util.UUID data class WhisperMessage( @@ -69,41 +68,6 @@ data class WhisperMessage( rawBadgeInfo = tags["badge-info"], ) } - - fun fromPubSub(data: WhisperData): WhisperMessage = with(data) { - val color = - data.tags.color - .ifBlank { null } - ?.let(Color::parseColor) - val recipientColor = - data.recipient.color - .ifBlank { null } - ?.let(Color::parseColor) - 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, - ) - } } } 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 1e1e9b1a2..1e252d1df 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 @@ -6,12 +6,10 @@ 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.client.HttpClient @@ -73,9 +71,6 @@ class PubSubConnection( val hasTopics: Boolean get() = topics.isNotEmpty() - val hasWhisperTopic: Boolean - get() = topics.any { it.topic.startsWith("whispers.") } - val events = receiveChannel.receiveAsFlow().distinctUntilChanged { old, new -> (old.isDisconnected && new.isDisconnected) || old == new @@ -285,15 +280,6 @@ class PubSubConnection( val match = topics.find { topic == it.topic } ?: return false val pubSubMessage = when (match) { - is PubSubTopic.Whispers -> { - if (messageTopic !in listOf("whisper_sent", "whisper_received")) { - return false - } - - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false - PubSubMessage.Whisper(parsedMessage.data) - } - is PubSubTopic.PointRedemptions -> { if (messageTopic !in POINT_REDEMPTION_TOPICS) { return false 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 fbeb2f17f..26a4f5474 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 @@ -52,9 +52,6 @@ class PubSubManager( val connected: Boolean get() = connections.any { it.connected } - val connectedAndHasWhisperTopic: Boolean - get() = connections.any { it.connected && it.hasWhisperTopic } - init { scope.launch { startupValidationHolder.awaitResolved() @@ -109,9 +106,6 @@ class PubSubManager( add(PubSubTopic.ModeratorActions(userId = uid, channelId = channel.id, channelName = channel.name)) } } - if (shouldUsePubSub) { - add(PubSubTopic.Whispers(uid)) - } } private fun listen(topics: Set) { 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 27d9ad114..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 { @@ -15,10 +14,6 @@ sealed interface PubSubMessage { val data: PointRedemptionData, ) : PubSubMessage - data class Whisper( - val data: WhisperData, - ) : PubSubMessage - data class ModeratorAction( val timestamp: Instant, val channelId: UserId, 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 3356f3957..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 @@ -11,10 +11,6 @@ sealed class PubSubTopic( 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, 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 956127bf2..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/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/ui/chat/mention/MentionViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionViewModel.kt index 8d37efcd7..81e736909 100644 --- 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 @@ -4,6 +4,7 @@ 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.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore @@ -20,14 +21,17 @@ 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.android.annotation.KoinViewModel @KoinViewModel class MentionViewModel( - chatNotificationRepository: ChatNotificationRepository, + private val chatNotificationRepository: ChatNotificationRepository, private val chatMessageMapper: ChatMessageMapper, private val preferenceStore: DankChatPreferenceStore, appearanceSettingsDataStore: AppearanceSettingsDataStore, @@ -48,10 +52,19 @@ class MentionViewModel( 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() } @@ -61,6 +74,13 @@ class MentionViewModel( .map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), persistentListOf()) + init { + combine(whispers, currentTab) { _, tab -> tab } + .filter { it == 1 } + .onEach { clearWhisperMentionCount() } + .launchIn(viewModelScope) + } + val mentionsUiStates: Flow> = combine( mentions, 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 index 0c342bb61..758d2156f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -231,6 +231,7 @@ fun MainScreen( dialogViewModel = dialogViewModel, chatInputViewModel = chatInputViewModel, channelTabViewModel = channelTabViewModel, + sheetNavigationViewModel = sheetNavigationViewModel, mainScreenViewModel = mainScreenViewModel, preferenceStore = preferenceStore, ) @@ -576,7 +577,7 @@ fun MainScreen( currentStream = currentStream, isAudioOnly = isAudioOnly, streamHeightDp = streamState.heightDp, - totalMentionCount = tabState.tabs.sumOf { it.mentionCount }, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount } + tabState.whisperMentionCount, onAction = handleToolbarAction, onAudioOnly = { streamViewModel.toggleAudioOnly() }, onStreamClose = { streamViewModel.closeStream() }, 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 index 36dd25388..502f29ffa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.auth.StartupValidation @@ -27,6 +28,7 @@ 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 @@ -38,6 +40,7 @@ fun MainScreenEventHandler( dialogViewModel: DialogStateViewModel, chatInputViewModel: ChatInputViewModel, channelTabViewModel: ChannelTabViewModel, + sheetNavigationViewModel: SheetNavigationViewModel, mainScreenViewModel: MainScreenViewModel, preferenceStore: DankChatPreferenceStore, ) { @@ -83,9 +86,13 @@ fun MainScreenEventHandler( } is MainEvent.OpenChannel -> { - channelTabViewModel.selectTab( - preferenceStore.channels.indexOf(event.channel), - ) + if (event.channel == UserName.EMPTY) { + sheetNavigationViewModel.openWhispers() + } else { + channelTabViewModel.selectTab( + preferenceStore.channels.indexOf(event.channel), + ) + } (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) } 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 index 1d287b04c..d892ed336 100644 --- 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 @@ -11,6 +11,7 @@ data class ChannelTabUiState( val tabs: ImmutableList = persistentListOf(), val selectedIndex: Int = 0, val loading: Boolean = true, + val whisperMentionCount: Int = 0, ) @Immutable 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 index 917ff8b7b..0834388fd 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -67,6 +68,7 @@ class ChannelTabViewModel( loading = globalState == GlobalLoadingState.Loading || tabs.any { it.loadingState == ChannelLoadingState.Loading }, + whisperMentionCount = mentions[WhisperMessage.WHISPER_CHANNEL] ?: 0, ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) 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 index fc8604706..c6f6036d6 100644 --- 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 @@ -15,6 +15,7 @@ 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 @@ -22,10 +23,12 @@ 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 @@ -50,6 +53,7 @@ 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 @@ -101,8 +105,13 @@ fun MentionSheet( } 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 -> @@ -180,6 +189,10 @@ fun MentionSheet( color = textColor, style = MaterialTheme.typography.titleSmall, ) + if (index == 1 && whisperMentionCount > 0 && !isSelected) { + Spacer(Modifier.width(4.dp)) + Badge() + } } } } From 5b7a21d14a0fc1c370b6cf182b13fcf177b7f34b Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 08:28:01 +0200 Subject: [PATCH 246/349] fix(input): Make FAB show-input action work, hide last-message when input is hidden --- app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt | 2 +- app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index fa329c096..5fb6cfc3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -595,7 +595,7 @@ private fun getFabMenuItem( } InputAction.LastMessage -> { - FabMenuItem(R.string.input_action_last_message, Icons.Default.History) + null } InputAction.Stream -> { 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 index 758d2156f..c2b605e1c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -660,7 +660,7 @@ fun MainScreen( } InputAction.HideInput -> { - mainScreenViewModel.hideInput() + mainScreenViewModel.toggleInput() } InputAction.Debug -> { From 6a3599ceff7f3e800baf378a80e2f55b84abb2fb Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 09:39:27 +0200 Subject: [PATCH 247/349] feat(debug): Add graphics, code, stack, and PSS memory stats to debug view --- .../dankchat/data/debug/AppDebugSection.kt | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) 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 index 66e73e1c0..c138e9893 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt @@ -26,17 +26,32 @@ class AppDebugSection : DebugSection { val heapMax = runtime.maxMemory() val nativeAllocated = Debug.getNativeHeapAllocatedSize() val nativeTotal = Debug.getNativeHeapSize() - val totalAppMemory = heapUsed + nativeAllocated + + 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 = - listOf( - DebugEntry("Total app memory", formatBytes(totalAppMemory)), - DebugEntry("JVM heap", "${formatBytes(heapUsed)} / ${formatBytes(heapMax)}"), - DebugEntry("Native heap", "${formatBytes(nativeAllocated)} / ${formatBytes(nativeTotal)}"), - DebugEntry("Threads", "${Thread.activeCount()}"), - ), + 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()}")) + }, ) } } @@ -45,4 +60,9 @@ class AppDebugSection : DebugSection { val mb = bytes / (1024.0 * 1024.0) return "%.1f MB".format(mb) } + + private fun formatKb(kb: Long): String { + val mb = kb / 1024.0 + return "%.1f MB".format(mb) + } } From 2e7d12b749b28d5dc981d461c2ad2903ac4b6c4b Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 09:59:09 +0200 Subject: [PATCH 248/349] chore: Bump version to 4.0.6 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26b441a49..5b2324545 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40005 - versionName = "4.0.5" + versionCode = 40006 + versionName = "4.0.6" } androidResources { generateLocaleConfig = true } From ad3953f29c68f78ecfba57658cd785876675b6ab Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 10:16:03 +0200 Subject: [PATCH 249/349] fix(upload): Support JSON array indices in custom uploader link patterns --- .../dankchat/data/api/upload/UploadClient.kt | 75 +++++++----- .../data/api/upload/UploadLinkPatternTest.kt | 109 ++++++++++++++++++ 2 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/data/api/upload/UploadLinkPatternTest.kt 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 e42002069..0c9b67a62 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 @@ -12,13 +12,20 @@ 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 @@ -76,7 +83,7 @@ class UploadClient( } response - .asJsonObject() + .asJson() .mapCatching { json -> val deleteLink = deletionLinkPattern?.takeIf { it.isNotBlank() }?.let { json.extractLink(it) } val imageLink = json.extractLink(imageLinkPattern) @@ -97,43 +104,47 @@ 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(Dispatchers.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) + Log.d(TAG, "Error parsing JSON from response: ", it) } - 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() - } + companion object { + private val TAG = UploadClient::class.java.simpleName + private val LINK_PATTERN_REGEX = "\\{(.+?)\\}".toRegex() - value + @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 + else -> return@runCatching null + } + } + when (result) { + is JsonPrimitive -> result.content + is JsonObject, is JsonArray -> result.toString() + } + }.getOrNull() + } } } 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")) + } +} From 46c28fe306d57e30360e2b038078d1d2db582c6a Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 10:56:29 +0200 Subject: [PATCH 250/349] fix(settings): Disable TTS volume slider when TTS is off --- .../com/flxrs/dankchat/preferences/components/PreferenceItem.kt | 1 + 1 file changed, 1 insertion(+) 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 e32ece165..e9c815955 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 @@ -126,6 +126,7 @@ fun SliderPreferenceItem( onValueChangeFinished = onDragFinish, valueRange = range, steps = steps, + enabled = isEnabled, modifier = Modifier .weight(1f) From 8c358a6a832d60619fb7e1374e74f7f1394a7663 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 11:01:24 +0200 Subject: [PATCH 251/349] fix(input): Insert text at cursor position instead of always appending --- .../com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 0068e80fb..cec4e8a3b 100644 --- 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 @@ -484,9 +484,10 @@ class ChatInputViewModel( } fun insertText(text: String) { + val selection = textFieldState.selection textFieldState.edit { - append(text) - placeCursorAtEnd() + replace(selection.min, selection.max, text) + placeCursorBeforeCharAt(selection.min + text.length) } } From 9dd2d9f447ae89cd7f4e8a1b63d03de156953ebf Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 11:11:46 +0200 Subject: [PATCH 252/349] fix(input): Disable swipe-to-hide gesture when input is multiline --- .../kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 12 ++++++++---- .../dankchat/ui/main/input/ChatInputCallbacks.kt | 1 + .../flxrs/dankchat/ui/main/input/ChatInputLayout.kt | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) 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 index c2b605e1c..e48485a5f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -317,6 +317,7 @@ fun MainScreen( 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() } @@ -430,6 +431,7 @@ fun MainScreen( null }, onRepeatedSendChange = chatInputViewModel::setRepeatedSend, + onInputMultilineChanged = { isInputMultiline = it }, ), isUploading = dialogState.isUploading, isLoading = tabState.loading, @@ -763,6 +765,7 @@ fun MainScreen( isEmoteMenuOpen = inputState.isEmoteMenuOpen, isSheetOpen = isSheetOpen, showInput = showInput, + isInputMultiline = isInputMultiline, inputOverflowExpanded = inputOverflowExpanded, forceOverflowOpen = featureTourState.forceOverflowOpen, swipeDownThresholdPx = swipeDownThresholdPx, @@ -776,6 +779,7 @@ fun MainScreen( NormalStackedLayout( currentStream = currentStream, isAudioOnly = isAudioOnly, + isInputMultiline = isInputMultiline, onStreamClose = onStreamClose, onAudioOnly = onAudioOnly, hasWebViewBeenAttached = streamViewModel.hasWebViewBeenAttached, @@ -831,6 +835,7 @@ private fun BoxScope.WideSplitLayout( isEmoteMenuOpen: Boolean, isSheetOpen: Boolean, showInput: Boolean, + isInputMultiline: Boolean, inputOverflowExpanded: Boolean, forceOverflowOpen: Boolean, swipeDownThresholdPx: Float, @@ -841,8 +846,6 @@ private fun BoxScope.WideSplitLayout( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current var splitFraction by remember { mutableFloatStateOf(0.6f) } var containerWidthPx by remember { mutableIntStateOf(0) } @@ -924,7 +927,7 @@ private fun BoxScope.WideSplitLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = showInput && !isSheetOpen, + enabled = showInput && !isSheetOpen && !isInputMultiline, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), @@ -969,6 +972,7 @@ private fun BoxScope.WideSplitLayout( private fun BoxScope.NormalStackedLayout( currentStream: UserName?, isAudioOnly: Boolean, + isInputMultiline: Boolean, onStreamClose: () -> Unit, onAudioOnly: () -> Unit, hasWebViewBeenAttached: Boolean, @@ -1111,7 +1115,7 @@ private fun BoxScope.NormalStackedLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = showInput && !isSheetOpen, + enabled = showInput && !isSheetOpen && !isInputMultiline, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), 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 index f7b6e3f11..3cf6995ed 100644 --- 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 @@ -18,4 +18,5 @@ data class ChatInputCallbacks( 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 index 9f13fc3c1..532417c9c 100644 --- 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 @@ -251,6 +251,7 @@ fun ChatInputLayout( if (textFieldState.text.isEmpty()) { singleLineHeight = maxOf(singleLineHeight, size.height) } + callbacks.onInputMultilineChanged(singleLineHeight > 0 && size.height > singleLineHeight) }.focusRequester(focusRequester), label = { Text(hint) }, suffix = { From f0f1086c2e42e30768d02bd099c1083faf0bd972 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 11:31:46 +0200 Subject: [PATCH 253/349] fix(chat): Reduce timestamp-badge spacing, add horizontal message padding --- app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt | 2 ++ .../com/flxrs/dankchat/ui/chat/messages/AutomodMessage.kt | 4 ++-- .../kotlin/com/flxrs/dankchat/ui/chat/messages/PrivMessage.kt | 2 +- .../com/flxrs/dankchat/ui/chat/messages/SystemMessages.kt | 4 ++-- .../flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) 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 index 5fb6cfc3a..f242b676c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -196,6 +196,8 @@ fun ChatScreen( state = listState, reverseLayout = true, contentPadding = PaddingValues( + start = 4.dp, + end = 4.dp, top = contentPadding.calculateTopPadding() + MESSAGE_GAP, bottom = contentPadding.calculateBottomPadding() + MESSAGE_GAP, ), 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 index 486249c73..845714bad 100644 --- 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 @@ -100,8 +100,8 @@ fun AutomodMessageComposable( ), ) { append(message.timestamp) - append(" ") } + append("\u2009") } // Badges @@ -187,8 +187,8 @@ fun AutomodMessageComposable( ), ) { append(message.timestamp) - append(" ") } + append("\u2009") } // Username in bold with user color 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 index 3a429e98f..ab373986b 100644 --- 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 @@ -191,8 +191,8 @@ private fun PrivMessageText( if (message.timestamp.isNotEmpty()) { withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { append(message.timestamp) - append(" ") } + append("\u2009") } // Badges (using appendInlineContent for proper rendering) 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 index 3175a0a96..bf1bfdef4 100644 --- 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 @@ -99,7 +99,7 @@ fun UserNoticeMessageComposable( withStyle(timestampSpanStyle(textSize.value, timestampColor)) { append(message.timestamp) } - append(" ") + append("\u2009") } // Message text with colored display name @@ -270,7 +270,7 @@ fun ModerationMessageComposable( withStyle(timestampSpanStyle(textSize.value, timestampColor)) { append(message.timestamp) } - append(" ") + append("\u2009") } // Render message: highlighted ranges at full opacity, template text dimmed 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 index 8227192a0..2d937cabb 100644 --- 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 @@ -127,8 +127,8 @@ private fun WhisperMessageText( if (message.timestamp.isNotEmpty()) { withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { append(message.timestamp) - append(" ") } + append("\u2009") } // Badges (using appendInlineContent for proper rendering) @@ -268,7 +268,7 @@ fun PointRedemptionMessageComposable( withStyle(timestampSpanStyle(fontSize, timestampColor)) { append(message.timestamp) } - append(" ") + append("\u2009") } when { From 4361b9f71745c19993d19ae920b061a131ec7afc Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 11:51:06 +0200 Subject: [PATCH 254/349] fix(suggestions): Use emote type, id, and code in suggestion keys to prevent duplicates --- .../com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 8c628c899..eee094d5b 100644 --- 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 @@ -104,8 +104,8 @@ fun SuggestionDropdown( suggestions, key = { suggestion -> when (suggestion) { - is Suggestion.EmoteSuggestion -> "emote-${suggestion.emote.id}" - is Suggestion.UserSuggestion -> "user-$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.unicode}" is Suggestion.CommandSuggestion -> "cmd-${suggestion.command}" is Suggestion.FilterSuggestion -> "filter-${suggestion.keyword}" From adabaa2b53527e99dc434b497a7cc12ccd8be26b Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 12:54:24 +0200 Subject: [PATCH 255/349] fix(input): Only make helper text tappable when it can expand to two lines --- .../dankchat/ui/main/input/ChatInputLayout.kt | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) 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 index 532417c9c..a079bfbd1 100644 --- 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 @@ -858,28 +858,44 @@ internal fun ExpandableHelperText( BoxWithConstraints( modifier = modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - .animateContentSize(), + .fillMaxWidth(), ) { val maxWidthPx = with(density) { maxWidth.roundToPx() } val fitsOnOneLine = remember(combinedText, style, maxWidthPx) { textMeasurer.measure(combinedText, style).size.width <= maxWidthPx } - val showTwoLines = expanded && !fitsOnOneLine && streamInfoText != null && roomStateText.isNotEmpty() - when { - showTwoLines -> { - Column { - Text( - text = roomStateText, - style = style, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), - ) + 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 = streamInfoText, + text = combinedText, style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -887,16 +903,6 @@ internal fun ExpandableHelperText( ) } } - - else -> { - Text( - text = combinedText, - style = style, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), - ) - } } } } From b2ed88dde8e90aeb4e59016ac08e72cbc9514cf2 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 13:14:59 +0200 Subject: [PATCH 256/349] fix(chat): Remove elevation from recovery FABs to fix white rectangle on light theme --- app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt | 3 +++ 1 file changed, 3 insertions(+) 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 index f242b676c..dc5ffb060 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -47,6 +47,7 @@ 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 @@ -360,6 +361,7 @@ private fun RecoveryFabs( }, 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.FullscreenExit, @@ -461,6 +463,7 @@ private fun FabMenuToggle( 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, From db7f3ecf1f03300d5739b62c3391992c0b1b4db5 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 14:10:26 +0200 Subject: [PATCH 257/349] fix(chat): Show custom redeems as header, fix redemption text color, add reward icons to headers --- .../data/repo/chat/ChatEventProcessor.kt | 77 +++++++++++-------- .../data/twitch/message/PrivMessage.kt | 2 + .../dankchat/ui/chat/ChatMessageMapper.kt | 18 ++++- .../dankchat/ui/chat/ChatMessageUiState.kt | 4 + .../dankchat/ui/chat/messages/PrivMessage.kt | 24 ++++++ .../ui/chat/messages/WhisperAndRedemption.kt | 15 ++-- .../main/res/values-b+zh+Hant+TW/strings.xml | 4 +- app/src/main/res/values-be-rBY/strings.xml | 3 +- app/src/main/res/values-ca/strings.xml | 4 +- app/src/main/res/values-cs/strings.xml | 4 +- app/src/main/res/values-de-rDE/strings.xml | 3 +- app/src/main/res/values-en-rAU/strings.xml | 3 +- app/src/main/res/values-en-rGB/strings.xml | 3 +- app/src/main/res/values-en/strings.xml | 3 +- app/src/main/res/values-es-rES/strings.xml | 3 +- app/src/main/res/values-fi-rFI/strings.xml | 4 +- app/src/main/res/values-fr-rFR/strings.xml | 3 +- app/src/main/res/values-hu-rHU/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-ja-rJP/strings.xml | 4 +- app/src/main/res/values-kk-rKZ/strings.xml | 4 +- app/src/main/res/values-or-rIN/strings.xml | 4 +- app/src/main/res/values-pl-rPL/strings.xml | 4 +- app/src/main/res/values-pt-rBR/strings.xml | 3 +- app/src/main/res/values-pt-rPT/strings.xml | 3 +- app/src/main/res/values-ru-rRU/strings.xml | 3 +- app/src/main/res/values-sr/strings.xml | 4 +- app/src/main/res/values-tr-rTR/strings.xml | 4 +- app/src/main/res/values-uk-rUA/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- 30 files changed, 123 insertions(+), 100 deletions(-) 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 index 48d92d92b..d77eaf763 100644 --- 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 @@ -420,7 +420,8 @@ class ChatEventProcessor( return } - val additionalMessages = resolveRewardMessages(ircMessage) + val resolvedReward = resolveReward(ircMessage) + val additionalMessages = resolvedReward?.toStandaloneMessage().orEmpty() val message = runCatching { @@ -430,7 +431,8 @@ class ChatEventProcessor( }.getOrElse { Log.e(TAG, "Failed to parse message", it) return - }?.let { resolveAutomaticRewardCost(it) } ?: return + }?.let { resolveAutomaticRewardCost(it) } + ?.let { attachRewardInfo(it, resolvedReward) } ?: return if (message is NoticeMessage && usersRepository.isGlobalChannel(message.channel)) { chatMessageRepository.broadcastToAllChannels(ChatItem(message, importance = ChatImportance.SYSTEM)) @@ -483,35 +485,47 @@ class ChatEventProcessor( } } - private suspend fun resolveRewardMessages(ircMessage: IrcMessage): List { - val rewardId = ircMessage.tags["custom-reward-id"]?.takeIf { it.isNotEmpty() } ?: return emptyList() - val isAutomodApproval = knownAutomodHeldIds.remove(rewardId) - if (isAutomodApproval) { - return emptyList() + 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 } - 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) { - chatConnector.pubSubEvents - .filterIsInstance() - .first { it.data.reward.id == rewardId } - }?.also { knownRewards[rewardId] = it } - } - } + return 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) { + 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)) + } - return reward - ?.let { - val processed = messageProcessor.processReward(PointRedemptionMessage.parsePointReward(it.timestamp, it.data)) - listOfNotNull(processed?.let(::ChatItem)) - }.orEmpty() + 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 { @@ -527,8 +541,11 @@ class ChatEventProcessor( .first { it.data.reward.effectiveId == msgId } } - val cost = reward?.data?.reward?.effectiveCost ?: return message - return message.copy(rewardCost = cost) + 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) { 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 fa0a90d3d..8e12dc360 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 @@ -33,6 +33,8 @@ data class PrivMessage( val thread: MessageThreadHeader? = null, val replyMentionOffset: Int = 0, val rewardCost: Int? = null, + val rewardTitle: String? = null, + val rewardImageUrl: String? = null, override val emoteData: EmoteData = EmoteData( message = originalMessage, 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 index b85d6fdbb..95419f763 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -539,13 +539,11 @@ class ChatMessageMapper( val highlightHeader = when { isGigantifiedEmote -> { - rewardCost?.let { TextResource.Res(R.string.highlight_header_gigantified_emote_cost, persistentListOf(it)) } - ?: TextResource.Res(R.string.highlight_header_gigantified_emote) + TextResource.Res(R.string.highlight_header_gigantified_emote) } isAnimatedMessage -> { - rewardCost?.let { TextResource.Res(R.string.highlight_header_animated_message_cost, persistentListOf(it)) } - ?: TextResource.Res(R.string.highlight_header_animated_message) + TextResource.Res(R.string.highlight_header_animated_message) } isElevatedMessage -> { @@ -553,6 +551,10 @@ class ChatMessageMapper( ?: 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) } @@ -597,6 +599,12 @@ class ChatMessageMapper( isAction = isAction, thread = threadUi, highlightHeader = highlightHeader, + highlightHeaderImageUrl = rewardImageUrl, + highlightHeaderCost = rewardCost, + highlightHeaderCostSuffix = when { + isGigantifiedEmote || isAnimatedMessage -> "Bits" + else -> null + }, fullMessage = fullMessage, ) } @@ -615,6 +623,7 @@ class ChatMessageMapper( } val nameText = if (!requiresUserInput) aliasOrFormattedName else null + val nameColor = usersRepository.getCachedUserColor(name) ?: Message.DEFAULT_COLOR return ChatMessageUiState.PointRedemptionMessageUi( id = id, @@ -624,6 +633,7 @@ class ChatMessageMapper( darkBackgroundColor = backgroundColors.dark, textAlpha = textAlpha, nameText = nameText, + rawNameColor = nameColor, title = title, cost = cost, rewardImageUrl = rewardImageUrl, 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 index bd67ef72b..161aaea7a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -46,6 +46,9 @@ sealed interface ChatMessageUiState { 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 @@ -120,6 +123,7 @@ sealed interface ChatMessageUiState { override val enableRipple: Boolean = false, override val isHighlighted: Boolean = true, val nameText: String?, + val rawNameColor: Int, val title: String, val cost: Int, val rewardImageUrl: String, 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 index ab373986b..beece2e2e 100644 --- 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 @@ -32,6 +32,7 @@ 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 @@ -108,6 +109,29 @@ fun PrivMessageComposable( maxLines = 1, modifier = Modifier.padding(start = 4.dp), ) + 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), + ) + } } } 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 index 2d937cabb..8b236d0e3 100644 --- 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 @@ -245,7 +245,7 @@ fun PointRedemptionMessageComposable( highlightShape: Shape = RectangleShape, ) { val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) - val timestampColor = rememberAdaptiveTextColor(backgroundColor) + val textColor = rememberAdaptiveTextColor(backgroundColor) Box( modifier = @@ -256,16 +256,18 @@ fun PointRedemptionMessageComposable( .background(backgroundColor, highlightShape) .padding(horizontal = 2.dp, vertical = 2.dp), ) { + val nameColor = message.nameText?.let { rememberNormalizedColor(message.rawNameColor, backgroundColor) } + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { val annotatedString = - remember(message, timestampColor) { + remember(message, textColor, nameColor) { buildAnnotatedString { // Timestamp if (message.timestamp.isNotEmpty()) { - withStyle(timestampSpanStyle(fontSize, timestampColor)) { + withStyle(timestampSpanStyle(fontSize, textColor)) { append(message.timestamp) } append("\u2009") @@ -277,7 +279,7 @@ fun PointRedemptionMessageComposable( } message.nameText != null -> { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = nameColor ?: textColor)) { append(message.nameText) } append(" redeemed ") @@ -293,8 +295,7 @@ fun PointRedemptionMessageComposable( BasicText( text = annotatedString, - style = TextStyle(fontSize = fontSize.sp), - modifier = Modifier.weight(1f), + style = TextStyle(fontSize = fontSize.sp, color = textColor), ) AsyncImage( @@ -305,7 +306,7 @@ fun PointRedemptionMessageComposable( BasicText( text = " ${message.cost}", - style = TextStyle(fontSize = fontSize.sp), + style = TextStyle(fontSize = fontSize.sp, color = textColor), modifier = Modifier.padding(start = 4.dp), ) } 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 48c8dc21d..8d4feb69e 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 @@ -86,10 +86,8 @@ 首次聊天 固定聊天訊息 巨大表情 - 巨大表情 · %1$d Bits 動畫訊息 - 動畫訊息 · %1$d Bits - + 已兌換 %1$s %1$d 秒 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 09a94dfcc..0505f7df4 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -81,9 +81,8 @@ Першае паведамленне Павышанае паведамленне Гіганцкі эмоут - Гіганцкі эмоут · %1$d Bits Аніміраванае паведамленне - Аніміраванае паведамленне · %1$d Bits + Выкарыстана: %1$s %1$d секунду %1$d секунды diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 6a219fb87..0c3c7bf9a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -84,10 +84,8 @@ Primer missatge Missatge destacat Emote gegant - Emote gegant · %1$d Bits Missatge animat - Missatge animat · %1$d Bits - + Bescanviat %1$s %1$d segon %1$d segons %1$d segons diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 28759df13..7bd85c1c8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -81,10 +81,8 @@ První zpráva Zvýrazněná zpráva Obří emote - Obří emote · %1$d Bits Animovaná zpráva - Animovaná zpráva · %1$d Bits - + Uplatněno %1$s %1$d sekundu %1$d sekundy %1$d sekund diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index d2e6a561f..3b261be63 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -81,9 +81,8 @@ Erste Nachricht Hervorgehobene Nachricht Riesenemote - Riesenemote · %1$d Bits Animierte Nachricht - Animierte Nachricht · %1$d Bits + Eingelöst: %1$s %1$d Sekunde %1$d Sekunden diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 783e15367..714013ab7 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -77,9 +77,8 @@ First Time Chat Elevated Chat Gigantified Emote - Gigantified Emote · %1$d Bits Animated Message - Animated Message · %1$d Bits + Redeemed %1$s %1$d second %1$d seconds diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 943fc3654..7ba48dd79 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -77,9 +77,8 @@ First Time Chat Elevated Chat Gigantified Emote - Gigantified Emote · %1$d Bits Animated Message - Animated Message · %1$d Bits + Redeemed %1$s %1$d second %1$d seconds diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index fbc9dc33b..e0e1d7dd5 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -81,9 +81,8 @@ First Time Chat Elevated Chat Gigantified Emote - Gigantified Emote · %1$d Bits Animated Message - Animated Message · %1$d Bits + Redeemed %1$s %1$d second %1$d seconds diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index c07e3864e..faec92111 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -80,9 +80,8 @@ Primer mensaje Mensaje destacado Emote gigante - Emote gigante · %1$d Bits Mensaje animado - Mensaje animado · %1$d Bits + Canjeado %1$s %1$d segundo %1$d segundos diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 954585d1f..2dd79001b 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -83,10 +83,8 @@ Ensimmäinen viesti Korostettu viesti Jättimäinen emote - Jättimäinen emote · %1$d Bits Animoitu viesti - Animoitu viesti · %1$d Bits - + Lunastettu %1$s %1$d sekunti %1$d sekuntia diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 29edebd78..929802faa 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -81,9 +81,8 @@ Premier message Message mis en avant Emote géant - Emote géant · %1$d Bits Message animé - Message animé · %1$d Bits + Échangé %1$s %1$d seconde %1$d secondes diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 9775837c5..e46c5a3c8 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -81,10 +81,8 @@ Első üzenet Kiemelt üzenet Óriás emote - Óriás emote · %1$d Bits Animált üzenet - Animált üzenet · %1$d Bits - + Beváltva: %1$s %1$d másodperc %1$d másodperc diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 13958b0ef..b69df0489 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -80,9 +80,8 @@ Primo messaggio Messaggio elevato Emote gigante - Emote gigante · %1$d Bits Messaggio animato - Messaggio animato · %1$d Bits + Riscattato %1$s %1$d secondo %1$d secondi diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 922bc336f..d089bf554 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -80,10 +80,8 @@ 初めてのチャット ピン留めチャット 巨大エモート - 巨大エモート · %1$d Bits アニメーションメッセージ - アニメーションメッセージ · %1$d Bits - + 引き換え済み %1$s %1$d秒 diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index ce25885ae..5219f6105 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -85,10 +85,8 @@ Алғашқы рет чат Көтерілген чат Алып эмоут - Алып эмоут · %1$d Bits Анимациялық хабарлама - Анимациялық хабарлама · %1$d Bits - + Пайдаланылды: %1$s %1$d секунд %1$d секунд diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 265264900..aea3d501f 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -85,10 +85,8 @@ ପ୍ରଥମ ଥର ଚାଟ୍ ଉଚ୍ଚତର ଚାଟ୍ ବିଶାଳ ଇମୋଟ - ବିଶାଳ ଇମୋଟ · %1$d Bits ଆନିମେଟେଡ ବାର୍ତ୍ତା - ଆନିମେଟେଡ ବାର୍ତ୍ତା · %1$d Bits - + ରିଡିମ କରାଯାଇଛି %1$s %1$d ସେକେଣ୍ଡ %1$d ସେକେଣ୍ଡ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 5b1a358ae..0c8425164 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -80,10 +80,8 @@ Pierwsza wiadomość Podwyższony czat Gigantyczny emote - Gigantyczny emote · %1$d Bits Animowana wiadomość - Animowana wiadomość · %1$d Bits - + Wymieniono %1$s %1$d sekundę %1$d sekundy %1$d sekund diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 75ba0a2ec..b84c6c97e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -81,9 +81,8 @@ Primeira mensagem Mensagem elevada Emote gigante - Emote gigante · %1$d Bits Mensagem animada - Mensagem animada · %1$d Bits + Resgatado %1$s %1$d segundo %1$d segundos diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 1542fa5c7..270134ab3 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -81,9 +81,8 @@ Primeira mensagem Mensagem elevada Emote gigante - Emote gigante · %1$d Bits Mensagem animada - Mensagem animada · %1$d Bits + Resgatado %1$s %1$d segundo %1$d segundos diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3d8be8090..c49484c10 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -81,9 +81,8 @@ Первое сообщение Выделенное сообщение Гигантский эмоут - Гигантский эмоут · %1$d Bits Анимированное сообщение - Анимированное сообщение · %1$d Bits + Использовано: %1$s %1$d секунду %1$d секунды diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index ef7e53b89..950ff9f48 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -85,10 +85,8 @@ Прва порука Истакнута порука Гигантски емоут - Гигантски емоут · %1$d Bits Анимирана порука - Анимирана порука · %1$d Bits - + Искоришћено: %1$s %1$d секунду %1$d секунде %1$d секунди diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index a66ea4af5..809b1e7e8 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -80,10 +80,8 @@ İlk mesaj Yükseltilmiş mesaj Dev Emote - Dev Emote · %1$d Bits Animasyonlu Mesaj - Animasyonlu Mesaj · %1$d Bits - + Kullanıldı: %1$s %1$d saniye %1$d saniye diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 414f251ad..c165cf845 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -81,9 +81,8 @@ Перше повідомлення Піднесене повідомлення Гігантський емоут - Гігантський емоут · %1$d Bits Анімоване повідомлення - Анімоване повідомлення · %1$d Bits + Використано: %1$s %1$d секунду %1$d секунди diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32fdd19aa..4db5d19c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,9 +87,8 @@ First Time Chat Elevated Chat Gigantified Emote - Gigantified Emote · %1$d Bits Animated Message - Animated Message · %1$d Bits + Redeemed %1$s %1$d second From 3f1cda4ad04f322df7cf92ef816a15eaa5188cb9 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 14:45:22 +0200 Subject: [PATCH 258/349] refactor(settings): Replace marquee with auto-sizing text for preference titles --- .../flxrs/dankchat/preferences/components/PreferenceItem.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 e9c815955..410d2a3fa 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 @@ -273,7 +273,7 @@ private fun RowScope.PreferenceItemContent( Text( text = title, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + autoSize = TextAutoSize.StepBased(maxFontSize = MaterialTheme.typography.titleMedium.fontSize), maxLines = 1, color = color, ) From b89d6fed8b39c7aaf40c293f663b4db966310b90 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 14:52:14 +0200 Subject: [PATCH 259/349] fix(ui): Bold unread tab names for better contrast in light theme --- .../kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 30c938475..caec13ef9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -340,10 +340,11 @@ fun FloatingToolbar( ) { tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex + val hasActivity = tab.mentionCount > 0 || tab.hasUnread val textColor = when { isSelected -> MaterialTheme.colorScheme.primary - tab.mentionCount > 0 || tab.hasUnread -> MaterialTheme.colorScheme.onSurface + hasActivity -> MaterialTheme.colorScheme.onSurface else -> MaterialTheme.colorScheme.onSurfaceVariant } Row( @@ -370,7 +371,7 @@ fun FloatingToolbar( text = tab.displayName, color = textColor, style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + fontWeight = if (isSelected || hasActivity) FontWeight.Bold else FontWeight.Normal, ) if (tab.mentionCount > 0) { Spacer(Modifier.width(4.dp)) From 90733531dc6bb831c446f3213b2fba7162e92cdb Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 16:05:31 +0200 Subject: [PATCH 260/349] fix(snackbar): Fix error snackbar re-showing after navigation, reduce all durations to Short --- .../com/flxrs/dankchat/DankChatApplication.kt | 1 - .../dankchat/domain/ChannelDataCoordinator.kt | 24 +- .../preferences/chat/ChatSettingsScreen.kt | 5 +- .../developer/DeveloperSettingsScreen.kt | 2 +- .../ui/main/MainScreenEventHandler.kt | 64 +++--- .../dankchat/ui/main/MainScreenViewModel.kt | 67 ++++-- .../ui/main/LoadingFailureStateTest.kt | 213 ++++++++++++++++++ 7 files changed, 291 insertions(+), 85 deletions(-) create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/ui/main/LoadingFailureStateTest.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index b0f7a5a26..945ebbae3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -35,7 +35,6 @@ import org.koin.ksp.generated.module class DankChatApplication : Application(), SingletonImageLoader.Factory { - // Dummy comment to force KSP re-run private val dispatchersProvider: DispatchersProvider by inject() private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchersProvider.main) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt index 0c0c1600f..bace597ff 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -145,16 +145,8 @@ class ChannelDataCoordinator( val chatFailures = chatMessageRepository.chatLoadingFailures.value _globalLoadingState.value = when { - dataFailures.isEmpty() && chatFailures.isEmpty() -> { - GlobalLoadingState.Loaded - } - - else -> { - GlobalLoadingState.Failed( - failures = dataFailures, - chatFailures = chatFailures, - ) - } + dataFailures.isEmpty() && chatFailures.isEmpty() -> GlobalLoadingState.Loaded + else -> GlobalLoadingState.Failed(failures = dataFailures, chatFailures = chatFailures) } } } @@ -284,16 +276,8 @@ class ChannelDataCoordinator( val remainingChatFailures = chatMessageRepository.chatLoadingFailures.value _globalLoadingState.value = when { - remainingDataFailures.isEmpty() && remainingChatFailures.isEmpty() -> { - GlobalLoadingState.Loaded - } - - else -> { - GlobalLoadingState.Failed( - failures = remainingDataFailures, - chatFailures = remainingChatFailures, - ) - } + remainingDataFailures.isEmpty() && remainingChatFailures.isEmpty() -> GlobalLoadingState.Loaded + else -> GlobalLoadingState.Failed(failures = remainingDataFailures, chatFailures = remainingChatFailures) } } } 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 a3edc7992..d1d8698e5 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 @@ -70,11 +70,10 @@ fun ChatSettingsScreen( viewModel.events.collectLatest { when (it) { ChatSettingsEvent.RestartRequired -> { - val result = - snackbarHostState.showSnackbar( + val result = snackbarHostState.showSnackbar( message = restartRequiredTitle, actionLabel = restartRequiredAction, - duration = SnackbarDuration.Long, + duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) { ProcessPhoenix.triggerRebirth(context) 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 index 8166b9eaf..06a4d09d0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -101,7 +101,7 @@ fun DeveloperSettingsScreen(onBack: () -> Unit) { snackbarHostState.showSnackbar( message = restartRequiredTitle, actionLabel = restartRequiredAction, - duration = SnackbarDuration.Long, + duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) { ProcessPhoenix.triggerRebirth(context) 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 index 502f29ffa..14a18bbdf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -7,23 +7,18 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue 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.compose.collectAsStateWithLifecycle 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.auth.StartupValidation -import com.flxrs.dankchat.data.auth.StartupValidationHolder import com.flxrs.dankchat.data.repo.chat.toDisplayStrings import com.flxrs.dankchat.data.repo.data.toDisplayStrings -import com.flxrs.dankchat.data.state.GlobalLoadingState import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.ui.main.channel.ChannelTabViewModel import com.flxrs.dankchat.ui.main.dialog.DialogStateViewModel @@ -66,11 +61,10 @@ fun MainScreenEventHandler( .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( + val result = snackbarHostState.showSnackbar( message = resources.getString(R.string.snackbar_image_uploaded, event.url), actionLabel = resources.getString(R.string.snackbar_paste), - duration = SnackbarDuration.Long, + duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) { chatInputViewModel.insertText(event.url) @@ -79,10 +73,10 @@ fun MainScreenEventHandler( is MainEvent.UploadFailed -> { dialogViewModel.setUploading(false) - val message = - event.errorMessage?.let { resources.getString(R.string.snackbar_upload_failed_cause, it) } + 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.Long) + snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) } is MainEvent.OpenChannel -> { @@ -96,9 +90,7 @@ fun MainScreenEventHandler( (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) } - else -> { - Unit - } + else -> Unit } } } @@ -136,29 +128,29 @@ fun MainScreenEventHandler( } } - val startupValidationHolder: StartupValidationHolder = koinInject() - val startupValidation by startupValidationHolder.state.collectAsStateWithLifecycle() - val loadingState by mainScreenViewModel.globalLoadingState.collectAsStateWithLifecycle() - LaunchedEffect(loadingState, startupValidation) { - if (startupValidation !is StartupValidation.Validated) return@LaunchedEffect - val state = loadingState as? GlobalLoadingState.Failed ?: return@LaunchedEffect - 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) + 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) } - val result = - snackbarHostState.showSnackbar( - message = message, - actionLabel = resources.getString(R.string.snackbar_retry), - duration = SnackbarDuration.Long, - ) - if (result == SnackbarResult.ActionPerformed) { - mainScreenViewModel.retryDataLoading(state) } } } 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 index a8cae8b93..caf24983b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -36,11 +36,14 @@ class MainScreenViewModel( private val developerSettingsDataStore: DeveloperSettingsDataStore, private val userStateRepository: UserStateRepository, ) : ViewModel() { - val globalLoadingState: StateFlow = - channelDataCoordinator.globalLoadingState + 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( @@ -62,6 +65,21 @@ class MainScreenViewModel( }.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 } @@ -85,28 +103,6 @@ class MainScreenViewModel( } } } - } - - fun isModeratorInChannel(channel: UserName?): Boolean = userStateRepository.isModeratorInChannel(channel) - - // Keyboard height persistence — debounced to avoid thrashing during animation - private val _keyboardHeightUpdates = MutableSharedFlow(extraBufferCapacity = 1) - - private val _keyboardHeightPx = MutableStateFlow(0) - val keyboardHeightPx: StateFlow = _keyboardHeightPx.asStateFlow() - - fun setGestureToolbarHidden(hidden: Boolean) { - _gestureToolbarHidden.value = hidden - } - - fun hideInput() { - viewModelScope.launch { - appearanceSettingsDataStore.update { it.copy(showInput = false) } - } - } - - init { - channelDataCoordinator.loadGlobalData() viewModelScope.launch { _keyboardHeightUpdates @@ -122,6 +118,18 @@ class MainScreenViewModel( } } + 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 @@ -161,6 +169,12 @@ class MainScreenViewModel( _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) } @@ -170,6 +184,11 @@ class MainScreenViewModel( } } +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/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..27f78e342 --- /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"))) + ) + } +} From 90b65229f4c1c87cc7efb0cf161a67d0084979e6 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 16:30:55 +0200 Subject: [PATCH 261/349] feat(chat): Show reply message preview in input overlay, add long-press to reply thread header --- .../ui/chat/message/MessageOptionsState.kt | 1 + .../chat/message/MessageOptionsViewModel.kt | 1 + .../dankchat/ui/chat/messages/PrivMessage.kt | 10 ++++++--- .../ui/main/dialog/MainScreenDialogs.kt | 6 ++--- .../dankchat/ui/main/input/ChatInputLayout.kt | 14 ++++++++++++ .../ui/main/input/ChatInputUiState.kt | 1 + .../ui/main/input/ChatInputViewModel.kt | 22 ++++++++++++++----- 7 files changed, 44 insertions(+), 11 deletions(-) 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 index 6f84919bd..17164a12a 100644 --- 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 @@ -13,6 +13,7 @@ sealed interface MessageOptionsState { val messageId: String, val rootThreadId: String, val rootThreadName: UserName?, + val rootThreadMessage: String?, val replyName: UserName, val name: UserName, val originalMessage: String, 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 index f8ad0661f..69fcd3dcd 100644 --- 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 @@ -64,6 +64,7 @@ class MessageOptionsViewModel( messageId = message.id, rootThreadId = rootId ?: message.id, rootThreadName = thread?.name, + rootThreadMessage = thread?.message, replyName = name, name = name, originalMessage = originalMessage.orEmpty(), 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 index beece2e2e..c0ac0e204 100644 --- 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 @@ -1,7 +1,8 @@ package com.flxrs.dankchat.ui.chat.messages +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column @@ -58,6 +59,7 @@ import com.flxrs.dankchat.utils.resolve * - Clickable username and emotes * - Long-press to copy message */ +@OptIn(ExperimentalFoundationApi::class) @Suppress("LambdaParameterEventTrailing") @Composable fun PrivMessageComposable( @@ -141,8 +143,10 @@ fun PrivMessageComposable( modifier = Modifier .fillMaxWidth() - .clickable { onReplyClick(message.thread.rootId, message.thread.userName.toUserName()) } - .padding(top = 4.dp), + .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) 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 index 083a55f0a..1192ac454 100644 --- 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 @@ -362,7 +362,7 @@ private fun MessageOptionsDialogContainer( params: MessageOptionsParams, snackbarHostState: SnackbarHostState, onJumpToMessage: (String, UserName) -> Unit, - onSetReplying: (Boolean, String, UserName) -> Unit, + onSetReplying: (Boolean, String, UserName, String) -> Unit, onOpenReplies: (String, UserName) -> Unit, onDismiss: () -> Unit, ) { @@ -390,8 +390,8 @@ private fun MessageOptionsDialogContainer( onJumpToMessage(params.messageId, channel) } }, - onReply = { onSetReplying(true, s.messageId, s.replyName) }, - onReplyToOriginal = { onSetReplying(true, s.rootThreadId, s.rootThreadName ?: s.replyName) }, + 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 { 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 index a079bfbd1..6975432bc 100644 --- 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 @@ -96,6 +96,7 @@ 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 @@ -231,8 +232,10 @@ fun ChatInputLayout( 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, ) } @@ -572,6 +575,7 @@ private fun InputActionButton( private fun InputOverlayHeader( text: String, onDismiss: () -> Unit, + subtitle: String? = null, ) { Column { Row( @@ -599,6 +603,16 @@ private fun InputOverlayHeader( ) } } + 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), 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 index ea5a6dae5..4b5372c61 100644 --- 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 @@ -37,6 +37,7 @@ sealed interface InputOverlay { data class Reply( val name: UserName, + val message: String, ) : InputOverlay data class Whisper( 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 index cec4e8a3b..1041184c1 100644 --- 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 @@ -82,6 +82,7 @@ class ChatInputViewModel( 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) @@ -237,8 +238,9 @@ class ChatInputViewModel( _isReplying, _replyName, _replyMessageId, - ) { isReplying, replyName, replyMessageId -> - Triple(isReplying, replyName, replyMessageId) + _replyMessage, + ) { isReplying, replyName, replyMessageId, replyMessage -> + ReplyState(isReplying, replyName, replyMessageId, replyMessage) } val inputOverlayFlow = @@ -250,8 +252,7 @@ class ChatInputViewModel( _whisperTarget, _isAnnouncing, ) { sheetState, tab, replyState, isEmoteMenuOpen, whisperTarget, isAnnouncing -> - val (isReplying, replyName, replyMessageId) = replyState - InputOverlayState(sheetState, tab, isReplying, replyName, replyMessageId, isEmoteMenuOpen, whisperTarget, isAnnouncing) + InputOverlayState(sheetState, tab, replyState.isReplying, replyState.replyName, replyState.replyMessageId, replyState.replyMessage, isEmoteMenuOpen, whisperTarget, isAnnouncing) } return combine( @@ -297,9 +298,10 @@ class ChatInputViewModel( 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) + overlayState.isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName, effectiveReplyMessage) isWhisperTabActive && overlayState.whisperTarget != null -> InputOverlay.Whisper(overlayState.whisperTarget) overlayState.isAnnouncing -> InputOverlay.Announce else -> InputOverlay.None @@ -457,10 +459,12 @@ class ChatInputViewModel( 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) { @@ -590,12 +594,20 @@ private data class UiDependencies( val showCharacterCounter: Boolean, ) +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, From 27316b95dd198d33c45a731ebf5553447c868562 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 16:39:58 +0200 Subject: [PATCH 262/349] fix(ui): Match emote info tab row background to sheet container color --- .../com/flxrs/dankchat/ui/main/dialog/EmoteInfoDialog.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 6ff8938eb..2090ab2f5 100644 --- 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 @@ -60,7 +60,10 @@ fun EmoteInfoDialog( ) { Column(modifier = Modifier.fillMaxWidth()) { if (items.size > 1) { - PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { items.forEachIndexed { index, item -> Tab( selected = pagerState.currentPage == index, From b33c97ce32c77102632ce687409ec1fa9d702305 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 16:43:55 +0200 Subject: [PATCH 263/349] fix(theme): Remove expressive motion scheme to reduce bouncy animations --- .../main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt | 3 --- 1 file changed, 3 deletions(-) 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 index 3daefb58d..9c73e7e67 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -3,7 +3,6 @@ package com.flxrs.dankchat.ui.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 @@ -107,9 +106,7 @@ fun DankChatTheme(content: @Composable () -> Unit) { onSurfaceDark = darkColorScheme.onSurface, ) val colors = if (darkTheme) darkColorScheme else lightColorScheme - val motionScheme = remember { MotionScheme.expressive() } MaterialExpressiveTheme( - motionScheme = motionScheme, colorScheme = colors, ) { CompositionLocalProvider(LocalAdaptiveColors provides adaptiveColors) { From 16bdad321092dce360a5abfb39f4efeaa3da40c5 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 17:09:07 +0200 Subject: [PATCH 264/349] fix(ui): Dismiss keyboard before opening bottom sheets to prevent animation conflicts --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 19 +++++ .../ui/main/dialog/MainScreenDialogs.kt | 77 ++++++++++--------- .../dankchat/ui/main/input/ChatInputLayout.kt | 3 + 3 files changed, 64 insertions(+), 35 deletions(-) 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 index e48485a5f..b23b0ae17 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -225,6 +225,24 @@ fun MainScreen( 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, @@ -256,6 +274,7 @@ fun MainScreen( isStreamActive = currentStream != null, inputSheetState = inputSheetState, snackbarHostState = snackbarHostState, + sheetsReady = sheetsReady, onAddChannel = { channelManagementViewModel.addChannel(it) dialogViewModel.dismissAddChannel() 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 index 1192ac454..6e4b282cc 100644 --- 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 @@ -70,6 +70,7 @@ fun MainScreenDialogs( isStreamActive: Boolean, inputSheetState: InputSheetState, snackbarHostState: SnackbarHostState, + sheetsReady: Boolean, onAddChannel: (UserName) -> Unit, onLogout: () -> Unit, onLogin: () -> Unit, @@ -107,7 +108,7 @@ fun MainScreenDialogs( ) } - if (dialogState.showModActions && modActionsChannel != null) { + if (sheetsReady && dialogState.showModActions && modActionsChannel != null) { ModActionsDialogContainer( channel = modActionsChannel, isStreamActive = isStreamActive, @@ -206,46 +207,52 @@ fun MainScreenDialogs( ) } - dialogState.messageOptionsParams?.let { params -> - MessageOptionsDialogContainer( - params = params, - snackbarHostState = snackbarHostState, - onJumpToMessage = onJumpToMessage, - onSetReplying = chatInputViewModel::setReplying, - onOpenReplies = sheetNavigationViewModel::openReplies, - onDismiss = dialogViewModel::dismissMessageOptions, - ) + if (sheetsReady) { + dialogState.messageOptionsParams?.let { params -> + MessageOptionsDialogContainer( + params = params, + snackbarHostState = snackbarHostState, + onJumpToMessage = onJumpToMessage, + onSetReplying = chatInputViewModel::setReplying, + onOpenReplies = sheetNavigationViewModel::openReplies, + onDismiss = dialogViewModel::dismissMessageOptions, + ) + } } - dialogState.emoteInfoEmotes?.let { emotes -> - EmoteInfoDialogContainer( - emotes = emotes, - isLoggedIn = isLoggedIn, - onInsertText = chatInputViewModel::insertText, - onOpenUrl = onOpenUrl, - onDismiss = dialogViewModel::dismissEmoteInfo, - ) + if (sheetsReady) { + dialogState.emoteInfoEmotes?.let { emotes -> + EmoteInfoDialogContainer( + emotes = emotes, + isLoggedIn = isLoggedIn, + onInsertText = chatInputViewModel::insertText, + onOpenUrl = onOpenUrl, + onDismiss = dialogViewModel::dismissEmoteInfo, + ) + } } - dialogState.userPopupParams?.let { params -> - 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() - }, - onDismiss = dialogViewModel::dismissUserPopup, - ) + if (sheetsReady) { + dialogState.userPopupParams?.let { params -> + 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() + }, + onDismiss = dialogViewModel::dismissUserPopup, + ) + } } - if (inputSheetState is InputSheetState.DebugInfo) { + if (sheetsReady && inputSheetState is InputSheetState.DebugInfo) { val debugInfoViewModel: DebugInfoViewModel = koinViewModel() DebugInfoSheet( viewModel = debugInfoViewModel, 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 index 6975432bc..47da94937 100644 --- 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 @@ -90,6 +90,7 @@ 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 @@ -199,6 +200,7 @@ fun ChatInputLayout( }.toImmutableList() } + val keyboardController = LocalSoftwareKeyboardController.current var visibleActions by remember { mutableStateOf(effectiveActions) } val quickActionsExpanded = overflowExpanded || tourState.forceOverflowOpen var showConfigSheet by remember { mutableStateOf(false) } @@ -423,6 +425,7 @@ fun ChatInputLayout( }, onConfigureClick = { onOverflowExpandedChange(false) + keyboardController?.hide() showConfigSheet = true }, ) From b558ba843f293c679e203dcb9a6ce0d7f14ade07 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 17:50:07 +0200 Subject: [PATCH 265/349] perf(emotes): Cache emote lookup map, merge word splits, optimize emote tag parsing --- .../data/repo/emote/EmoteRepository.kt | 132 +++++++++++++----- .../dankchat/data/twitch/message/Message.kt | 63 ++++++--- .../ui/chat/search/ChatSearchFilterParser.kt | 4 +- 3 files changed, 140 insertions(+), 59 deletions(-) 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 a4cf79a5a..09af12a15 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 @@ -78,6 +78,12 @@ class EmoteRepository( 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() + fun getEmotes(channel: UserName): Flow { val channelFlow = channelEmoteStates.getOrPut(channel) { MutableStateFlow(ChannelEmoteState()) } return combine(globalEmoteState, channelFlow, ::mergeEmotes) @@ -89,6 +95,7 @@ class EmoteRepository( fun removeChannel(channel: UserName) { channelEmoteStates.remove(channel) + cachedEmoteMaps.remove(channel) } fun clearTwitchEmotes() { @@ -103,25 +110,7 @@ class EmoteRepository( channel: UserName, withTwitch: Boolean = false, ): List { - val globalState = globalEmoteState.value - val channelState = channelEmoteStates[channel]?.value ?: ChannelEmoteState() - 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 emoteMap = HashMap() - globalState.sevenTvEmotes.associateByTo(emoteMap) { it.code } - globalState.bttvEmotes.associateByTo(emoteMap) { it.code } - globalState.ffzEmotes.associateByTo(emoteMap) { it.code } - if (!isWhisper) { - channelState.sevenTvEmotes.associateByTo(emoteMap) { it.code } - channelState.bttvEmotes.associateByTo(emoteMap) { it.code } - channelState.ffzEmotes.associateByTo(emoteMap) { it.code } - } - if (withTwitch) { - globalState.twitchEmotes.associateByTo(emoteMap) { it.code } - channelState.twitchEmotes.associateByTo(emoteMap) { it.code } - } + val emoteMap = getOrBuildEmoteMap(channel, withTwitch) // Single pass through words var currentPosition = 0 @@ -144,6 +133,46 @@ class EmoteRepository( } } + 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 + } + } + + 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 { val replyMentionOffset = (message as? PrivMessage)?.replyMentionOffset ?: 0 val emoteData = message.emoteData ?: return message @@ -169,15 +198,13 @@ class EmoteRepository( replyMentionOffset = replyMentionOffset, ) val twitchEmoteCodes = twitchEmotes.mapTo(mutableSetOf()) { it.code } - val cheermotes = - when { - message is PrivMessage && message.tags["bits"] != null -> parseCheermotes(appendedSpaceAdjustedMessage, channel) - else -> emptyList() - } - val cheermoteCodes = cheermotes.mapTo(mutableSetOf()) { it.code } - val thirdPartyEmotes = - parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel) - .filterNot { it.code in twitchEmoteCodes || it.code in cheermoteCodes } + val hasBits = message is PrivMessage && message.tags["bits"] != null + val (thirdPartyEmotes, cheermotes) = parseNonTwitchEmotes( + message = appendedSpaceAdjustedMessage, + channel = channel, + excludeCodes = twitchEmoteCodes, + hasBits = hasBits, + ) val emotes = twitchEmotes + thirdPartyEmotes + cheermotes val (adjustedMessage, adjustedEmotes) = adjustOverlayEmotes(appendedSpaceAdjustedMessage, emotes) @@ -305,6 +332,12 @@ class EmoteRepository( } } + private data class CachedEmoteMap( + val globalState: GlobalEmoteState, + val channelState: ChannelEmoteState, + val map: Map, + ) + data class TagListEntry( val key: String, val value: String, @@ -664,22 +697,32 @@ class EmoteRepository( } } - private fun parseCheermotes( + private fun parseNonTwitchEmotes( message: String, channel: UserName, - ): List { - val cheermoteSets = channelEmoteStates[channel]?.value?.cheermoteSets - if (cheermoteSets.isNullOrEmpty()) return emptyList() + excludeCodes: Set, + hasBits: Boolean, + ): Pair, List> { + val emoteMap = getOrBuildEmoteMap(channel, withTwitch = false) + val cheermoteSets = if (hasBits) { + channelEmoteStates[channel]?.value?.cheermoteSets.orEmpty() + } else { + emptyList() + } + val thirdPartyEmotes = mutableListOf() + val cheermotes = mutableListOf() var currentPosition = 0 - return buildList { - message.split(WHITESPACE_REGEX).forEach { word -> + + 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 - this += + cheermotes += ChatMessageEmote( position = currentPosition..currentPosition + word.length, url = tier.animatedUrl, @@ -690,12 +733,29 @@ class EmoteRepository( cheerAmount = bits, cheerColor = tier.color, ) + matchedCheermote = true break } } - currentPosition += word.length + 1 } + 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 } + + return thirdPartyEmotes to cheermotes } private fun UserEmoteDto.toGenericEmote(type: EmoteType): GenericEmote { 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 8a3993bfb..3ec688efd 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 @@ -46,30 +46,49 @@ sealed class Message { message: String, tag: String, ): List { - return tag.split('/').mapNotNull { emote -> - val split = emote.split(':') - // bad emote data :) - if (split.size != 2) return@mapNotNull null - - val (id, positions) = split - val pairs = positions.split(',') - // bad emote data :) - if (pairs.isEmpty()) return@mapNotNull null - - // skip over invalid parsed data - val parsedPositions = - pairs.mapNotNull positions@{ pos -> - val pair = pos.split('-') - if (pair.size != 2) return@positions null - - val start = pair[0].toIntOrNull() ?: return@positions null - val end = pair[1].toIntOrNull() ?: return@positions null - - // be extra safe in case twitch sends invalid emote ranges :) - start.coerceAtLeast(minimumValue = 0)..end.coerceAtMost(message.length) + if (tag.isEmpty()) return emptyList() + + return buildList { + var emoteStart = 0 + while (emoteStart < tag.length) { + val slashIdx = tag.indexOf('/', emoteStart) + val emoteEnd = if (slashIdx == -1) tag.length else slashIdx + + val colonIdx = tag.indexOf(':', emoteStart) + // bad emote data :) + if (colonIdx == -1 || colonIdx >= emoteEnd) { + emoteStart = emoteEnd + 1 + continue + } + + 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 + + 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 + } + + if (positions.isNotEmpty()) { + this += EmoteWithPositions(id, positions) } - EmoteWithPositions(id, parsedPositions) + emoteStart = emoteEnd + 1 + } } } } 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 index 94fff1337..ec023f372 100644 --- 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 @@ -1,10 +1,12 @@ 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("\\s+".toRegex()) + val tokens = query.trim().split(WHITESPACE_REGEX) val lastTokenIncomplete = !query.endsWith(' ') return tokens.mapIndexedNotNull { index, token -> From 522710ffc26adb3e1d4cb0b9e8896afa176b6026 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 17:50:16 +0200 Subject: [PATCH 266/349] style: Fix indentation in ChatSettingsScreen, MainScreenEventHandler, DankChatApplication --- .../com/flxrs/dankchat/DankChatApplication.kt | 1 - .../preferences/chat/ChatSettingsScreen.kt | 8 +++--- .../ui/main/MainScreenEventHandler.kt | 28 ++++++++++--------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 945ebbae3..8ae4c0c93 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -35,7 +35,6 @@ import org.koin.ksp.generated.module class DankChatApplication : Application(), SingletonImageLoader.Factory { - private val dispatchersProvider: DispatchersProvider by inject() private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchersProvider.main) } 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 d1d8698e5..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 @@ -71,10 +71,10 @@ fun ChatSettingsScreen( when (it) { ChatSettingsEvent.RestartRequired -> { val result = snackbarHostState.showSnackbar( - message = restartRequiredTitle, - actionLabel = restartRequiredAction, - duration = SnackbarDuration.Short, - ) + message = restartRequiredTitle, + actionLabel = restartRequiredAction, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { ProcessPhoenix.triggerRebirth(context) } 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 index 14a18bbdf..33277c2d7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -62,10 +62,10 @@ fun MainScreenEventHandler( ?.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, - ) + 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) } @@ -75,7 +75,7 @@ fun MainScreenEventHandler( dialogViewModel.setUploading(false) val message = event.errorMessage ?.let { resources.getString(R.string.snackbar_upload_failed_cause, it) } - ?: resources.getString(R.string.snackbar_upload_failed) + ?: resources.getString(R.string.snackbar_upload_failed) snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) } @@ -90,7 +90,9 @@ fun MainScreenEventHandler( (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) } - else -> Unit + else -> { + Unit + } } } } @@ -140,14 +142,14 @@ fun MainScreenEventHandler( 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) - } + 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, - ) + message = message, + actionLabel = resources.getString(R.string.snackbar_retry), + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { mainScreenViewModel.retryDataLoading(state) } From 67c96b71e5098d621abcfccb4bbf31fc5e4df42e Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 19:11:10 +0200 Subject: [PATCH 267/349] fix(ui): Remove bold from system message names, refactor moderation dedup --- .../ui/chat/messages/SystemMessages.kt | 17 ++--- .../ui/chat/messages/WhisperAndRedemption.kt | 2 +- .../utils/extensions/ModerationOperations.kt | 68 ++++++++++++------- 3 files changed, 48 insertions(+), 39 deletions(-) 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 index bf1bfdef4..99905c4a9 100644 --- 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 @@ -18,7 +18,6 @@ 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 @@ -121,7 +120,7 @@ fun UserNoticeMessageComposable( } // Colored username - withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { + withStyle(SpanStyle(color = nameColor)) { append(msgText.substring(nameIndex, nameIndex + displayName.length)) } @@ -186,7 +185,6 @@ private data class StyledRange( val start: Int, val length: Int, val color: Color, - val bold: Boolean, ) /** @@ -245,21 +243,21 @@ fun ModerationMessageComposable( message.creatorName?.let { name -> val idx = resolvedMessage.indexOf(name, startIndex = searchFrom, ignoreCase = true) if (idx >= 0) { - add(StyledRange(idx, name.length, creatorColor, bold = true)) + 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, bold = true)) + 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, bold = false)) + add(StyledRange(idx, arg.length, textColor)) } } }.sortedBy { it.start } @@ -282,12 +280,7 @@ fun ModerationMessageComposable( append(resolvedMessage.substring(cursor, range.start)) } } - val style = - when { - range.bold -> SpanStyle(color = range.color, fontWeight = FontWeight.Bold) - else -> SpanStyle(color = range.color) - } - withStyle(style) { + withStyle(SpanStyle(color = range.color)) { append(resolvedMessage.substring(range.start, range.start + range.length)) } cursor = range.start + range.length 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 index 8b236d0e3..0a0b089c6 100644 --- 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 @@ -279,7 +279,7 @@ fun PointRedemptionMessageComposable( } message.nameText != null -> { - withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = nameColor ?: textColor)) { + withStyle(SpanStyle(color = nameColor ?: textColor)) { append(message.nameText) } append(" redeemed ") 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 index 75b35d7a2..cd82ba445 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -12,7 +12,7 @@ fun MutableList.replaceOrAddHistoryModerationMessage(moderationMessage return } - if (checkForStackedTimeouts(moderationMessage)) { + if (deduplicateOrStack(moderationMessage)) { add(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM)) } } @@ -27,7 +27,7 @@ fun List.replaceOrAddModerationMessage( return this } - val addSystemMessage = checkForStackedTimeouts(moderationMessage) + val addSystemMessage = deduplicateOrStack(moderationMessage) for (idx in indices) { val item = this[idx] when (moderationMessage.action) { @@ -92,36 +92,52 @@ fun List.replaceWithTimeout( addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), 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 - } +/** + * 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 + } - if ((moderationMessage.timestamp - message.timestamp).milliseconds >= 5.seconds) { - 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 != moderationMessage.action) { + continue + } - when { - !moderationMessage.fromEventSource && message.fromEventSource -> { - Unit - } + // Different moderation action on the same user, treat as new + if ((moderationMessage.timestamp - existing.timestamp).milliseconds >= 5.seconds) { + return true + } - moderationMessage.fromEventSource && !message.fromEventSource -> { - this[idx] = item.copy(tag = item.tag + 1, message = moderationMessage) - } + // Same action within 5 seconds — deduplicate or stack + when { + // IRC arriving after EventSub → keep EventSub, discard IRC + !moderationMessage.fromEventSource && existing.fromEventSource -> { + Unit + } - 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) - } + // EventSub arriving after IRC → replace IRC with EventSub (has moderator info) + moderationMessage.fromEventSource && !existing.fromEventSource -> { + this[idx] = item.copy(tag = item.tag + 1, message = moderationMessage) + } + + // Same source, stackable action → increment stack count + moderationMessage.action == ModerationMessage.Action.Timeout || moderationMessage.action == ModerationMessage.Action.SharedTimeout -> { + val stackedMessage = moderationMessage.copy(stackCount = existing.stackCount + 1) + this[idx] = item.copy(tag = item.tag + 1, message = stackedMessage) } - return false } + return false } return true From d309f9629d7e5e37465e8af760076e836f83a2c5 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 19:19:45 +0200 Subject: [PATCH 268/349] fix(ui): Use SemiBold for unread tabs, keep Bold for selected --- .../kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index caec13ef9..19ed49f47 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -371,7 +371,11 @@ fun FloatingToolbar( text = tab.displayName, color = textColor, style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected || hasActivity) FontWeight.Bold else FontWeight.Normal, + fontWeight = when { + isSelected -> FontWeight.Bold + hasActivity -> FontWeight.SemiBold + else -> FontWeight.Normal + }, ) if (tab.mentionCount > 0) { Spacer(Modifier.width(4.dp)) From 1f567ad2a2b988a55133ce3036c1ccfc04926913 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 19:32:42 +0200 Subject: [PATCH 269/349] fix(ui): Close toolbar menu when keyboard opens, dismiss keyboard when menu opens --- .../com/flxrs/dankchat/ui/main/FloatingToolbar.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 index 19ed49f47..2ddeaf20b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -87,6 +87,7 @@ 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 @@ -150,13 +151,22 @@ fun FloatingToolbar( val tabWidths = remember { mutableStateOf(IntArray(0)) } var tabViewportWidth by remember { mutableIntStateOf(0) } - // Reset menus when toolbar hides + 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 + } + } // Dismiss scrim for menus if (showOverflowMenu || showQuickSwitch) { @@ -626,6 +636,7 @@ fun FloatingToolbar( showQuickSwitch = false overflowInitialMenu = AppBarMenu.Main showOverflowMenu = !showOverflowMenu + keyboardController?.hide() }) { Icon( imageVector = Icons.Default.MoreVert, From 30dc7ac56551d0a705cca248cfcf7b2760085499 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 20:06:46 +0200 Subject: [PATCH 270/349] fix(ui): Animate status bar scrim crossfade when toolbar appears/disappears --- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 14 ++++++++------ .../dankchat/ui/main/MainScreenComponents.kt | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) 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 index b23b0ae17..fe98ce441 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -927,9 +927,10 @@ private fun BoxScope.WideSplitLayout( showTabsInSplit, ) - if (!isFullscreen && gestureToolbarHidden) { - StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) - } + AnimatedStatusBarScrim( + visible = !isFullscreen && gestureToolbarHidden, + modifier = Modifier.align(Alignment.TopCenter), + ) fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) @@ -1112,9 +1113,10 @@ private fun BoxScope.NormalStackedLayout( ) } - if (!isInPipMode && !isFullscreen && gestureToolbarHidden) { - StatusBarScrim(modifier = Modifier.align(Alignment.TopCenter)) - } + AnimatedStatusBarScrim( + visible = !isInPipMode && !isFullscreen && gestureToolbarHidden, + modifier = Modifier.align(Alignment.TopCenter), + ) if (!isInPipMode) { fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) 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 index 31c9554f2..824d72f79 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -6,6 +6,8 @@ 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 @@ -104,6 +106,21 @@ internal fun FullscreenSystemBarsEffect(isFullscreen: Boolean) { } } +@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, From ba2a341b9513b94e0e01d601a57771e245045553 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 20:17:40 +0200 Subject: [PATCH 271/349] fix(data): Include step name in failure logs, increase HTTP timeouts to 30s --- .../com/flxrs/dankchat/data/repo/data/DataRepository.kt | 5 +++-- app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) 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 6b1e85b15..afd318fd3 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 @@ -298,8 +298,9 @@ class DataRepository( } private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = onFailure { throwable -> - Log.e(TAG, "Data request failed:", throwable) - _dataLoadingFailures.update { it + DataLoadingFailure(step(), throwable) } + val loadingStep = step() + Log.e(TAG, "Data request failed [$loadingStep]:", throwable) + _dataLoadingFailures.update { it + DataLoadingFailure(loadingStep, throwable) } } companion object { 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 e656a616a..2b659f22c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -94,9 +94,9 @@ class NetworkModule { json(json) } install(HttpTimeout) { - connectTimeoutMillis = 15_000 - requestTimeoutMillis = 15_000 - socketTimeoutMillis = 15_000 + connectTimeoutMillis = 30_000 + requestTimeoutMillis = 30_000 + socketTimeoutMillis = 30_000 } } From 8a9d6e07e31d759e5d29ae7f4ba267ad55734089 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 20:53:07 +0200 Subject: [PATCH 272/349] chore: Bump version to 4.0.7 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5b2324545..d8c195dd2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40006 - versionName = "4.0.6" + versionCode = 40007 + versionName = "4.0.7" } androidResources { generateLocaleConfig = true } From 5a1a433c318ebe1952fcd1e8a25079bf129ffaae Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 22:17:59 +0200 Subject: [PATCH 273/349] fix(chat): Make URLs in system and notice messages clickable --- .../dankchat/ui/chat/messages/SystemMessages.kt | 13 ++++++++++++- .../chat/messages/common/SimpleMessageContainer.kt | 12 ++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) 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 index 99905c4a9..39412757c 100644 --- 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 @@ -5,6 +5,7 @@ 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.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,9 +22,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.LocalPlatformContext import com.flxrs.dankchat.ui.chat.ChatMessageUiState import com.flxrs.dankchat.ui.chat.messages.common.SimpleMessageContainer +import com.flxrs.dankchat.ui.chat.messages.common.URL_ANNOTATION_TAG 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.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.messages.common.rememberBackgroundColor @@ -83,6 +87,7 @@ fun UserNoticeMessageComposable( modifier: Modifier = Modifier, highlightShape: Shape = RectangleShape, ) { + val context = LocalPlatformContext.current val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = MaterialTheme.colorScheme.onSurface val linkColor = rememberAdaptiveLinkColor(bgColor) @@ -152,10 +157,16 @@ fun UserNoticeMessageComposable( .background(bgColor, highlightShape) .padding(horizontal = 2.dp, vertical = 2.dp), ) { - Text( + ClickableText( text = annotatedString, style = TextStyle(fontSize = textSize), modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + val url = annotatedString.getStringAnnotations(URL_ANNOTATION_TAG, offset, offset).firstOrNull() + if (url != null) { + launchCustomTab(context, url.item) + } + }, ) } } 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 index 4e92f8e7c..f648dad1a 100644 --- 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 @@ -5,8 +5,8 @@ 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.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -18,6 +18,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import coil3.compose.LocalPlatformContext @Composable fun SimpleMessageContainer( @@ -29,6 +30,7 @@ fun SimpleMessageContainer( textAlpha: Float, modifier: Modifier = Modifier, ) { + val context = LocalPlatformContext.current val bgColor = rememberBackgroundColor(lightBackgroundColor, darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) val linkColor = rememberAdaptiveLinkColor(bgColor) @@ -56,10 +58,16 @@ fun SimpleMessageContainer( .background(bgColor) .padding(horizontal = 2.dp, vertical = 2.dp), ) { - Text( + ClickableText( text = annotatedString, style = TextStyle(fontSize = fontSize), modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + val url = annotatedString.getStringAnnotations(URL_ANNOTATION_TAG, offset, offset).firstOrNull() + if (url != null) { + launchCustomTab(context, url.item) + } + }, ) } } From 786e51187003cc0b85be9e9eed4d0baa75443a95 Mon Sep 17 00:00:00 2001 From: Felix Date: Thu, 2 Apr 2026 23:31:28 +0200 Subject: [PATCH 274/349] fix(ui): Allow taps and long-presses through edge gesture guards --- .../dankchat/ui/main/MainScreenComponents.kt | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) 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 index 824d72f79..843d062d7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -12,8 +12,7 @@ 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.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -36,7 +35,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.AbsoluteAlignment 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.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -157,8 +155,8 @@ internal fun InputDismissScrim( } /** - * Invisible touch-consuming boxes at the left and right screen edges. - * Prevents the HorizontalPager from intercepting system back/edge gestures. + * Invisible boxes at the left and right screen edges that consume horizontal drags + * to prevent the HorizontalPager from intercepting system back/edge gestures. */ @Composable internal fun BoxScope.EdgeGestureGuards() { @@ -170,13 +168,8 @@ internal fun BoxScope.EdgeGestureGuards() { Modifier .fillMaxHeight() .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown(pass = PointerEventPass.Initial) - down.consume() - do { - val event = awaitPointerEvent(pass = PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } while (event.changes.any { it.pressed }) + detectHorizontalDragGestures { change, _ -> + change.consume() } } From 7cb82f301c42b4b3a9979b6a4b848960a51d320c Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 00:00:21 +0200 Subject: [PATCH 275/349] fix(ui): Use outlined bell icon when no unread mentions --- .../kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 2ddeaf20b..cd9eddedb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -42,6 +42,7 @@ 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 @@ -621,7 +622,10 @@ fun FloatingToolbar( if (isLoggedIn) { IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { Icon( - imageVector = Icons.Default.Notifications, + imageVector = when { + totalMentionCount > 0 -> Icons.Default.Notifications + else -> Icons.Outlined.Notifications + }, contentDescription = stringResource(R.string.mentions_title), tint = if (totalMentionCount > 0) { From 7abc6cbebcf7937f8e2bb3dacaec013105a71f0f Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 00:02:20 +0200 Subject: [PATCH 276/349] chore: Bump version to 4.0.8 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8c195dd2..b7077926a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40007 - versionName = "4.0.7" + versionCode = 40008 + versionName = "4.0.8" } androidResources { generateLocaleConfig = true } From f640a47d7db084cb94f8fc5a76ea69d5ec932651 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 14:04:47 +0200 Subject: [PATCH 277/349] fix(ui): Use outlined icons for contextual input actions with primary tint --- .../dankchat/ui/main/QuickActionsMenu.kt | 12 +-- .../dankchat/ui/main/input/ChatInputLayout.kt | 100 +++++++++++------- .../ui/main/input/InputActionConfig.kt | 8 +- .../dankchat/ui/main/stream/AudioOnlyBar.kt | 4 +- 4 files changed, 75 insertions(+), 49 deletions(-) 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 index 488edff9b..995ae81c9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -18,10 +18,10 @@ 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.Shield -import androidx.compose.material.icons.filled.Videocam -import androidx.compose.material.icons.filled.VideocamOff 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 @@ -117,7 +117,7 @@ fun QuickActionsMenu( enabled = enabled, leadingIcon = { Icon( - imageVector = if (isAudioOnly) Icons.Default.Videocam else Icons.Default.Headphones, + imageVector = if (isAudioOnly) Icons.Outlined.Videocam else Icons.Default.Headphones, contentDescription = null, ) }, @@ -203,7 +203,7 @@ private fun getOverflowItem( hasStreamData || isStreamActive -> { OverflowItem( labelRes = if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream, - icon = if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + icon = if (isStreamActive) Icons.Outlined.VideocamOff else Icons.Outlined.Videocam, ) } @@ -218,7 +218,7 @@ private fun getOverflowItem( isModerator -> { OverflowItem( labelRes = R.string.menu_mod_actions, - icon = Icons.Default.Shield, + icon = Icons.Outlined.Shield, ) } 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 index 47da94937..01c6cd0f3 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -39,23 +40,24 @@ 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.EmojiEmotions 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.Keyboard 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.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 @@ -108,6 +110,7 @@ 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 @@ -516,44 +519,66 @@ private fun InputActionButton( modifier: Modifier = Modifier, onDebugInfoClick: () -> Unit = {}, ) { - val (icon, contentDescription, onClick) = - when (action) { - InputAction.Search -> { - Triple(Icons.Default.Search, R.string.message_history, onSearchClick) - } + val primary = MaterialTheme.colorScheme.primary + val contextualTint = when { + !isSystemInDarkTheme() -> primary + else -> primary.blend(to = MaterialTheme.colorScheme.onSurface, amount = 0.2f) + } - InputAction.LastMessage -> { - Triple(Icons.Default.History, R.string.resume_scroll, onLastMessageClick) - } + 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.Stream -> { - Triple( - if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, - R.string.toggle_stream, - onToggleStream, - ) - } + InputAction.LastMessage -> { + icon = Icons.Default.History + contentDescription = R.string.resume_scroll + onClick = onLastMessageClick + tint = null + } - InputAction.ModActions -> { - Triple(Icons.Default.Shield, R.string.menu_mod_actions, onModActions) - } + InputAction.Stream -> { + icon = if (isStreamActive) Icons.Outlined.VideocamOff else Icons.Outlined.Videocam + contentDescription = R.string.toggle_stream + onClick = onToggleStream + tint = contextualTint + } - InputAction.Fullscreen -> { - Triple( - if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, - R.string.toggle_fullscreen, - onToggleFullscreen, - ) - } + InputAction.ModActions -> { + icon = Icons.Outlined.Shield + contentDescription = R.string.menu_mod_actions + onClick = onModActions + tint = contextualTint + } - InputAction.HideInput -> { - Triple(Icons.Default.VisibilityOff, R.string.menu_hide_input, onToggleInput) - } + InputAction.Fullscreen -> { + icon = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen + contentDescription = R.string.toggle_fullscreen + onClick = onToggleFullscreen + tint = null + } - InputAction.Debug -> { - Triple(Icons.Default.BugReport, R.string.input_action_debug, onDebugInfoClick) - } + 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) { @@ -570,6 +595,7 @@ private fun InputActionButton( Icon( imageVector = icon, contentDescription = stringResource(contentDescription), + tint = tint ?: LocalContentColor.current, ) } } @@ -684,7 +710,7 @@ private fun InputActionsRow( modifier = Modifier.size(iconSize), ) { Icon( - imageVector = if (isEmoteMenuOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, + imageVector = if (isEmoteMenuOpen) Icons.Outlined.Keyboard else Icons.Outlined.EmojiEmotions, contentDescription = stringResource( if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint, 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 index 49e16cbb6..d67949ca5 100644 --- 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 @@ -16,9 +16,9 @@ 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.Shield -import androidx.compose.material.icons.filled.Videocam 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 @@ -205,8 +205,8 @@ internal val InputAction.icon: ImageVector when (this) { InputAction.Search -> Icons.Default.Search InputAction.LastMessage -> Icons.Default.History - InputAction.Stream -> Icons.Default.Videocam - InputAction.ModActions -> Icons.Default.Shield + 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/stream/AudioOnlyBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/AudioOnlyBar.kt index 0be6d10d5..a053c26ae 100644 --- 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 @@ -7,7 +7,7 @@ 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.filled.Videocam +import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -56,7 +56,7 @@ fun AudioOnlyBar( ) IconButton(onClick = onExpandVideo) { Icon( - imageVector = Icons.Default.Videocam, + imageVector = Icons.Outlined.Videocam, contentDescription = stringResource(R.string.menu_show_stream), ) } From a94496b41a40e93e0568a5dbf27bb15573ecc053 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 15:05:24 +0200 Subject: [PATCH 278/349] fix(chat): Full-width backgrounds, overlay dividers, adaptive text color on highlights --- .../dankchat/data/repo/RepliesRepository.kt | 7 +-- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 56 +++++++++++-------- .../ui/chat/messages/AutomodMessage.kt | 2 +- .../dankchat/ui/chat/messages/PrivMessage.kt | 2 +- .../ui/chat/messages/SystemMessages.kt | 8 +-- .../ui/chat/messages/WhisperAndRedemption.kt | 4 +- .../chat/messages/common/AdaptiveTextColor.kt | 9 ++- .../messages/common/SimpleMessageContainer.kt | 2 +- .../ui/main/LoadingFailureStateTest.kt | 4 +- 9 files changed, 51 insertions(+), 43 deletions(-) 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 a6fdd9423..33caab45d 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 @@ -5,7 +5,6 @@ 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.HighlightType import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.MessageThread import com.flxrs.dankchat.data.twitch.message.MessageThreadHeader @@ -26,8 +25,8 @@ class RepliesRepository( 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()) @@ -184,8 +183,6 @@ class RepliesRepository( ) } - private fun PrivMessage.clearHighlight(): PrivMessage = copy(highlights = highlights.filter { it.type != HighlightType.Reply }.toSet()) - companion object { private const val PARENT_MESSAGE_ID_TAG = "reply-parent-msg-id" private const val PARENT_MESSAGE_LOGIN_TAG = "reply-parent-user-login" 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 index dc5ffb060..682f439e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -197,8 +197,6 @@ fun ChatScreen( state = listState, reverseLayout = true, contentPadding = PaddingValues( - start = 4.dp, - end = 4.dp, top = contentPadding.calculateTopPadding() + MESSAGE_GAP, bottom = contentPadding.calculateBottomPadding() + MESSAGE_GAP, ), @@ -227,19 +225,24 @@ fun ChatScreen( // reverseLayout=true: index 0 = bottom (newest), index+1 = visually above val below = reversedMessages.getOrNull(index - 1) val above = reversedMessages.getOrNull(index + 1) - val highlightShape = message.highlightShape(above, below, showLineSeparator) - ChatMessageItem( - message = message, - highlightShape = highlightShape, - fontSize = fontSize, - showChannelPrefix = showChannelPrefix, - animateGifs = animateGifs, - callbacks = callbacks, - ) - - // Add divider after each message if enabled - if (showLineSeparator) { - HorizontalDivider() + val highlightShape = message.highlightShape(above, below) + Box { + ChatMessageItem( + message = message, + highlightShape = highlightShape, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + callbacks = callbacks, + ) + + if (showLineSeparator && below != null && !isHighlightBoundary(message, below)) { + val dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + HorizontalDivider( + modifier = Modifier.align(Alignment.BottomCenter), + color = dividerColor, + ) + } } } } @@ -645,22 +648,31 @@ private fun getFabMenuItem( } private val MESSAGE_GAP = 4.dp -private val HIGHLIGHT_CORNER_RADIUS = 8.dp +private val HIGHLIGHT_CORNER_RADIUS = 6.dp private fun ChatMessageUiState.highlightShape( above: ChatMessageUiState?, below: ChatMessageUiState?, - showLineSeparator: Boolean, ): Shape { if (!isHighlighted) return RectangleShape - if (showLineSeparator) return RectangleShape - val sameAbove = above != null && above.lightBackgroundColor == lightBackgroundColor && above.darkBackgroundColor == darkBackgroundColor - val sameBelow = below != null && below.lightBackgroundColor == lightBackgroundColor && below.darkBackgroundColor == darkBackgroundColor - val top = if (sameAbove) 0.dp else HIGHLIGHT_CORNER_RADIUS - val bottom = if (sameBelow) 0.dp else HIGHLIGHT_CORNER_RADIUS + + val top = if (hasSameBackground(above)) 0.dp else HIGHLIGHT_CORNER_RADIUS + val bottom = if (hasSameBackground(below)) 0.dp else HIGHLIGHT_CORNER_RADIUS return RoundedCornerShape(topStart = top, topEnd = top, bottomStart = bottom, bottomEnd = bottom) } +private fun ChatMessageUiState.hasSameBackground(other: ChatMessageUiState?): Boolean = + other != null && other.lightBackgroundColor == lightBackgroundColor && other.darkBackgroundColor == darkBackgroundColor + +private fun isHighlightBoundary( + current: ChatMessageUiState, + below: ChatMessageUiState?, +): Boolean { + val currentEndsHighlight = current.isHighlighted && !current.hasSameBackground(below) + val belowStartsHighlight = below != null && below.isHighlighted && !below.hasSameBackground(current) + return currentEndsHighlight || belowStartsHighlight +} + /** * Renders a single chat message based on its type */ 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 index 845714bad..56dead24e 100644 --- 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 @@ -241,7 +241,7 @@ fun AutomodMessageComposable( .fillMaxWidth() .wrapContentHeight() .alpha(resolvedAlpha) - .padding(horizontal = 2.dp, vertical = 2.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), ) { // Header line with badge inline content TextWithMeasuredInlineContent( 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 index c0ac0e204..0177f884c 100644 --- 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 @@ -85,7 +85,7 @@ fun PrivMessageComposable( .alpha(message.textAlpha) .background(backgroundColor, highlightShape) .indication(interactionSource, ripple()) - .padding(horizontal = 2.dp, vertical = 2.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), ) { // Highlight type header (First Time Chat, Elevated Chat) if (message.highlightHeader != null) { 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 index 39412757c..71cf435db 100644 --- 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 @@ -89,9 +89,9 @@ fun UserNoticeMessageComposable( ) { val context = LocalPlatformContext.current val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) - val textColor = MaterialTheme.colorScheme.onSurface + val textColor = rememberAdaptiveTextColor(bgColor) val linkColor = rememberAdaptiveLinkColor(bgColor) - val timestampColor = MaterialTheme.colorScheme.onSurface + val timestampColor = rememberAdaptiveTextColor(bgColor) val nameColor = rememberNormalizedColor(message.rawNameColor, bgColor) val textSize = fontSize.sp @@ -155,7 +155,7 @@ fun UserNoticeMessageComposable( .wrapContentHeight() .alpha(message.textAlpha) .background(bgColor, highlightShape) - .padding(horizontal = 2.dp, vertical = 2.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), ) { ClickableText( text = annotatedString, @@ -311,7 +311,7 @@ fun ModerationMessageComposable( .wrapContentHeight() .alpha(message.textAlpha) .background(bgColor) - .padding(horizontal = 2.dp, vertical = 2.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), ) { Text( text = annotatedString, 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 index 0a0b089c6..7b27d8a42 100644 --- 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 @@ -74,7 +74,7 @@ fun WhisperMessageComposable( .alpha(message.textAlpha) .background(backgroundColor) .indication(interactionSource, ripple()) - .padding(horizontal = 2.dp, vertical = 2.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), ) { Box(modifier = Modifier.weight(1f)) { WhisperMessageText( @@ -254,7 +254,7 @@ fun PointRedemptionMessageComposable( .wrapContentHeight() .alpha(message.textAlpha) .background(backgroundColor, highlightShape) - .padding(horizontal = 2.dp, vertical = 2.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), ) { val nameColor = message.nameText?.let { rememberNormalizedColor(message.rawNameColor, backgroundColor) } 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 index b05b2b634..7c74c85d3 100644 --- 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 @@ -37,13 +37,12 @@ private fun resolveEffectiveBackground(backgroundColor: Color): Color { 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 if (isLightBackground) { - adaptiveColors.onSurfaceLight - } else { - adaptiveColors.onSurfaceDark + return remember(baseColor, effectiveBgArgb) { + Color(baseColor.toArgb().normalizeColor(effectiveBgArgb)) } } 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 index f648dad1a..fb143895d 100644 --- 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 @@ -56,7 +56,7 @@ fun SimpleMessageContainer( .wrapContentHeight() .alpha(textAlpha) .background(bgColor) - .padding(horizontal = 2.dp, vertical = 2.dp), + .padding(horizontal = 6.dp, vertical = 3.dp), ) { ClickableText( text = annotatedString, 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 index 27f78e342..14856e999 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/ui/main/LoadingFailureStateTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/LoadingFailureStateTest.kt @@ -204,10 +204,10 @@ internal class LoadingFailureStateTest { companion object { private val FAILURE_1 = GlobalLoadingState.Failed( - failures = setOf(DataLoadingFailure(DataLoadingStep.DankChatBadges, RuntimeException("test"))) + failures = setOf(DataLoadingFailure(DataLoadingStep.DankChatBadges, RuntimeException("test"))), ) private val FAILURE_2 = GlobalLoadingState.Failed( - failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBadges, RuntimeException("test"))) + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBadges, RuntimeException("test"))), ) } } From 9d91cdebd683f851d5e35fae518fc3fe7bd2ba2e Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 15:21:49 +0200 Subject: [PATCH 279/349] fix(ui): Rework edge gesture guards to not block taps on content --- .../dankchat/ui/main/MainScreenComponents.kt | 63 ++++++++++--------- .../ui/main/MainScreenPagerContent.kt | 4 +- 2 files changed, 34 insertions(+), 33 deletions(-) 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 index 843d062d7..502565bc5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -12,18 +12,16 @@ 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.detectHorizontalDragGestures +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.BoxScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxHeight 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.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -32,10 +30,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.AbsoluteAlignment 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 @@ -49,6 +48,7 @@ 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 { @@ -155,40 +155,43 @@ internal fun InputDismissScrim( } /** - * Invisible boxes at the left and right screen edges that consume horizontal drags + * 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 BoxScope.EdgeGestureGuards() { +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() - val edgeGuardModifier = - Modifier - .fillMaxHeight() - .pointerInput(Unit) { - detectHorizontalDragGestures { change, _ -> + 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() } - } - - // Left edge guard - Box( - modifier = - Modifier - .align(AbsoluteAlignment.CenterLeft) - .width(with(density) { systemGestureInsets.getLeft(density, layoutDirection).toDp() }) - .then(edgeGuardModifier), - ) - // Right edge guard - Box( - modifier = - Modifier - .align(AbsoluteAlignment.CenterRight) - .width(with(density) { systemGestureInsets.getRight(density, layoutDirection).toDp() }) - .then(edgeGuardModifier), - ) + } while (true) + } + } } /** 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 index d45c68d46..543fcfd46 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -105,7 +105,7 @@ internal fun MainScreenPagerContent( Box(modifier = Modifier.fillMaxSize()) { HorizontalPager( state = composePagerState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().edgeGestureGuard(), userScrollEnabled = swipeNavigation, key = { index -> pagerState.channels.getOrNull(index)?.value ?: index }, ) { page -> @@ -192,8 +192,6 @@ internal fun MainScreenPagerContent( ) } } - - EdgeGestureGuards() } } } From 143634acb7ac54673c0644d7448846369e06732f Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 15:36:13 +0200 Subject: [PATCH 280/349] fix(colors): Restore hue correction as first step in username color normalization --- .../utils/extensions/ColorExtensions.kt | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) 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 c92782d33..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,23 +2,24 @@ package com.flxrs.dankchat.utils.extensions import androidx.annotation.ColorInt import androidx.core.graphics.ColorUtils +import kotlin.math.sin /** * Adjusts this color to ensure readable contrast against [background]. * - * Uses a relaxed contrast ratio of 3.5:1 (below WCAG AA 4.5:1) to - * preserve the original color as much as possible while still being readable. - * Shifts lightness in HSL space, preserving hue and saturation. + * 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 { - // calculateContrast requires opaque colors; force full alpha on both - val opaqueColor = this or 0xFF000000.toInt() + // 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 this + if (contrast >= MIN_CONTRAST_RATIO) return opaqueColor val hsl = FloatArray(3) ColorUtils.colorToHSL(opaqueColor, hsl) @@ -66,6 +67,44 @@ fun Int.normalizeColor( 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 && hue > 0.1f && hue < 0.33333f) { + hsl[2] -= (sin((hue - 0.1f) / (0.33333f - 0.1f) * Math.PI.toFloat()) * hsl[1] * 0.4f) + } + } + + else -> { + if (hsl[2] < 0.5f) { + hsl[2] = 0.5f + } + 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) + } + } + } + + return ColorUtils.HSLToColor(hsl) +} + private const val MIN_CONTRAST_RATIO = 3.5 private const val MAX_ITERATIONS = 16 From b7c389cad8495dcd2e5202b84cf05af250650c8c Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 16:04:04 +0200 Subject: [PATCH 281/349] chore: Bump version to 4.0.9 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7077926a..e5b0dbb37 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40008 - versionName = "4.0.8" + versionCode = 40009 + versionName = "4.0.9" } androidResources { generateLocaleConfig = true } From 0ffd16cabe9d8f04a1ef3a6413c13bc170b5d51e Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 21:03:59 +0200 Subject: [PATCH 282/349] refactor(chat): Move highlight shape and divider logic from composable to ViewModel layer --- .../flxrs/dankchat/ui/chat/ChatComposable.kt | 1 - .../dankchat/ui/chat/ChatMessageMapper.kt | 36 ++++++++++++++++ .../dankchat/ui/chat/ChatMessageUiState.kt | 30 +++++++++++++ .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 33 +++----------- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 7 +-- .../chat/history/MessageHistoryViewModel.kt | 24 ++++++----- .../ui/chat/mention/MentionComposable.kt | 1 - .../ui/chat/mention/MentionViewModel.kt | 43 +++++++++++-------- .../ui/chat/replies/RepliesComposable.kt | 1 - .../ui/chat/replies/RepliesViewModel.kt | 25 +++++------ .../ui/main/sheet/MessageHistorySheet.kt | 1 - 11 files changed, 126 insertions(+), 76 deletions(-) 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 index 1baf14853..161a05afd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -60,7 +60,6 @@ fun ChatComposable( onAutomodAllow = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = true) }, onAutomodDeny = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = false) }, ), - showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = modifier.fillMaxSize(), showInput = showInput, 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 index 95419f763..98cc077fd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -136,6 +136,22 @@ class ChatMessageMapper( } } + 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, @@ -924,3 +940,23 @@ class ChatMessageMapper( 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 index 161aaea7a..67aa536d6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -23,6 +23,9 @@ sealed interface ChatMessageUiState { val textAlpha: Float val enableRipple: Boolean val isHighlighted: Boolean + val roundedTopCorners: Boolean + val roundedBottomCorners: Boolean + val showDividerBelow: Boolean @Immutable data class PrivMessageUi( @@ -34,6 +37,9 @@ sealed interface ChatMessageUiState { 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, @@ -62,6 +68,9 @@ sealed interface ChatMessageUiState { 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 @@ -75,6 +84,9 @@ sealed interface ChatMessageUiState { 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 @@ -88,6 +100,9 @@ sealed interface ChatMessageUiState { 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, @@ -104,6 +119,9 @@ sealed interface ChatMessageUiState { 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, val creatorName: String? = null, val targetName: String? = null, @@ -122,6 +140,9 @@ sealed interface ChatMessageUiState { 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, @@ -140,6 +161,9 @@ sealed interface ChatMessageUiState { 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 @@ -153,6 +177,9 @@ sealed interface ChatMessageUiState { 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, @@ -176,6 +203,9 @@ sealed interface ChatMessageUiState { 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, 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 index 682f439e0..f4aa9ee00 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -119,7 +119,6 @@ fun ChatScreen( modifier: Modifier = Modifier, scrollModifier: Modifier = Modifier, showChannelPrefix: Boolean = false, - showLineSeparator: Boolean = false, animateGifs: Boolean = true, showInput: Boolean = true, isFullscreen: Boolean = false, @@ -221,22 +220,18 @@ fun ChatScreen( is ChatMessageUiState.DateSeparatorUi -> "datesep" } }, - ) { index, message -> - // reverseLayout=true: index 0 = bottom (newest), index+1 = visually above - val below = reversedMessages.getOrNull(index - 1) - val above = reversedMessages.getOrNull(index + 1) - val highlightShape = message.highlightShape(above, below) + ) { _, message -> Box { ChatMessageItem( message = message, - highlightShape = highlightShape, + highlightShape = message.toHighlightShape(), fontSize = fontSize, showChannelPrefix = showChannelPrefix, animateGifs = animateGifs, callbacks = callbacks, ) - if (showLineSeparator && below != null && !isHighlightBoundary(message, below)) { + if (message.showDividerBelow) { val dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) HorizontalDivider( modifier = Modifier.align(Alignment.BottomCenter), @@ -650,29 +645,13 @@ private fun getFabMenuItem( private val MESSAGE_GAP = 4.dp private val HIGHLIGHT_CORNER_RADIUS = 6.dp -private fun ChatMessageUiState.highlightShape( - above: ChatMessageUiState?, - below: ChatMessageUiState?, -): Shape { +private fun ChatMessageUiState.toHighlightShape(): Shape { if (!isHighlighted) return RectangleShape - - val top = if (hasSameBackground(above)) 0.dp else HIGHLIGHT_CORNER_RADIUS - val bottom = if (hasSameBackground(below)) 0.dp else HIGHLIGHT_CORNER_RADIUS + 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) } -private fun ChatMessageUiState.hasSameBackground(other: ChatMessageUiState?): Boolean = - other != null && other.lightBackgroundColor == lightBackgroundColor && other.darkBackgroundColor == darkBackgroundColor - -private fun isHighlightBoundary( - current: ChatMessageUiState, - below: ChatMessageUiState?, -): Boolean { - val currentEndsHighlight = current.isHighlighted && !current.hasSameBackground(below) - val belowStartsHighlight = below != null && below.isHighlighted && !below.hasSameBackground(current) - return currentEndsHighlight || belowStartsHighlight -} - /** * Renders a single chat message based on its type */ 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 index c1159f1e0..3f0a8eb7b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -56,7 +56,6 @@ class ChatViewModel( ) { appearance, chat -> ChatDisplaySettings( fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, animateGifs = chat.animateGifs, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) @@ -138,7 +137,10 @@ class ChatViewModel( } } - result.toImmutableList() + chatMessageMapper + .run { + result.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) + }.toImmutableList() }.flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), persistentListOf()) @@ -172,6 +174,5 @@ class ChatViewModel( @Immutable data class ChatDisplaySettings( val fontSize: Float = 14f, - val showLineSeparator: Boolean = false, val animateGifs: Boolean = true, ) 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 index 27b1830d2..dce3efa02 100644 --- 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 @@ -63,7 +63,6 @@ class MessageHistoryViewModel( ) { appearance, chat -> ChatDisplaySettings( fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, animateGifs = chat.animateGifs, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) @@ -88,16 +87,19 @@ class MessageHistoryViewModel( appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, ) { messages, activeFilters, appearanceSettings, chatSettings -> - messages - .filter { ChatItemFilter.matches(it, activeFilters) } - .mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) + chatMessageMapper + .run { + messages + .filter { ChatItemFilter.matches(it, activeFilters) } + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) }.toImmutableList() }.flowOn(Dispatchers.Default) 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 index e1469ec2a..71b000949 100644 --- 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 @@ -52,7 +52,6 @@ fun MentionComposable( onEmoteClick = onEmoteClick, onWhisperReply = if (isWhisperTab) onWhisperReply else null, ), - showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, showChannelPrefix = !isWhisperTab, modifier = modifier, 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 index 81e736909..eb4daa918 100644 --- 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 @@ -44,7 +44,6 @@ class MentionViewModel( ) { appearance, chat -> ChatDisplaySettings( fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, animateGifs = chat.animateGifs, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) @@ -87,15 +86,18 @@ class MentionViewModel( appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, ) { messages, appearanceSettings, chatSettings -> - messages - .mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) + chatMessageMapper + .run { + messages + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) }.toImmutableList() }.flowOn(Dispatchers.Default) @@ -105,15 +107,18 @@ class MentionViewModel( appearanceSettingsDataStore.settings, chatSettingsDataStore.settings, ) { messages, appearanceSettings, chatSettings -> - messages - .mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) + chatMessageMapper + .run { + messages + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) }.toImmutableList() }.flowOn(Dispatchers.Default) } 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 index 71216c02d..c850447e1 100644 --- 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 @@ -47,7 +47,6 @@ fun RepliesComposable( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, ), - showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = modifier, contentPadding = contentPadding, 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 index 5699b6f7f..506cd7220 100644 --- 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 @@ -37,7 +37,6 @@ class RepliesViewModel( ) { appearance, chat -> ChatDisplaySettings( fontSize = appearance.fontSize.toFloat(), - showLineSeparator = appearance.lineSeparator, animateGifs = chat.animateGifs, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) @@ -64,17 +63,19 @@ class RepliesViewModel( } is RepliesState.Found -> { - val uiMessages = - repliesState.items - .mapIndexed { index, item -> - val altBg = index.isEven && appearanceSettings.checkeredMessages - chatMessageMapper.mapToUiState( - item = item, - chatSettings = chatSettings, - preferenceStore = preferenceStore, - isAlternateBackground = altBg, - ) - }.toImmutableList() + val uiMessages = chatMessageMapper + .run { + repliesState.items + .mapIndexed { index, item -> + val altBg = index.isEven && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) + }.toImmutableList() RepliesUiState.Found(uiMessages) } } 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 index 5f546e62d..3c73c7a9e 100644 --- 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 @@ -156,7 +156,6 @@ fun MessageHistorySheet( onMessageLongClick = onMessageLongClick, onEmoteClick = onEmoteClick, ), - showLineSeparator = displaySettings.showLineSeparator, animateGifs = displaySettings.animateGifs, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), From 73b3a538c249011a4729d21518042dd5222787df Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 21:10:46 +0200 Subject: [PATCH 283/349] refactor: Clean up constructor parameters that don't need to be stored as properties --- app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt | 2 +- .../com/flxrs/dankchat/ui/chat/replies/RepliesViewModel.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 313c4118c..f09ab6cdf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -19,10 +19,10 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class DankChatViewModel( private val authDataStore: AuthDataStore, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, private val dataRepository: DataRepository, private val chatChannelProvider: ChatChannelProvider, private val authStateCoordinator: AuthStateCoordinator, + appearanceSettingsDataStore: AppearanceSettingsDataStore, ) : ViewModel() { val serviceEvents = dataRepository.serviceEvents val activeChannel = chatChannelProvider.activeChannel 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 index 506cd7220..9681fdaca 100644 --- 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 @@ -26,9 +26,9 @@ class RepliesViewModel( @InjectedParam private val rootMessageId: String, repliesRepository: RepliesRepository, private val chatMessageMapper: ChatMessageMapper, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val chatSettingsDataStore: ChatSettingsDataStore, private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( From 2ce5a54cbb510657d69917174d3ab3caaac26d36 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 21:18:47 +0200 Subject: [PATCH 284/349] chore: Bump version to 4.0.10 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5b0dbb37..14b31e034 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40009 - versionName = "4.0.9" + versionCode = 40010 + versionName = "4.0.10" } androidResources { generateLocaleConfig = true } From 66dcb77c132d3c37f997bf64ac80c5177c4e13a3 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 22:07:36 +0200 Subject: [PATCH 285/349] fix(mod): Persist shield mode state across dialog reopens via ShieldModeRepository --- .../data/repo/ShieldModeRepository.kt | 36 +++++++++++++++++++ .../twitch/command/TwitchCommandRepository.kt | 3 ++ .../ui/main/dialog/ModActionsViewModel.kt | 28 +++++---------- 3 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/ShieldModeRepository.kt 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/twitch/command/TwitchCommandRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommandRepository.kt index 84a594bd8..14e3cb03f 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 @@ -17,6 +17,7 @@ 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 @@ -31,6 +32,7 @@ import java.util.UUID class TwitchCommandRepository( private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore, + private val shieldModeRepository: ShieldModeRepository, ) { fun isIrcCommand(trigger: String): Boolean = trigger in ALLOWED_IRC_COMMAND_TRIGGERS @@ -783,6 +785,7 @@ class TwitchCommandRepository( return helixApiClient.putShieldMode(context.channelId, currentUserId, request).fold( onSuccess = { + shieldModeRepository.setState(context.channel, it.isActive) val response = when { it.isActive -> TextResource.Res(R.string.cmd_shield_activated) 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 index 3d533b8e1..58b8c7478 100644 --- 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 @@ -3,13 +3,11 @@ 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.api.helix.HelixApiClient 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.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 @@ -17,27 +15,17 @@ import org.koin.core.annotation.InjectedParam @KoinViewModel class ModActionsViewModel( @InjectedParam private val channel: UserName, - private val helixApiClient: HelixApiClient, - private val authDataStore: AuthDataStore, - private val channelRepository: ChannelRepository, + private val shieldModeRepository: ShieldModeRepository, + channelRepository: ChannelRepository, + authDataStore: AuthDataStore, ) : ViewModel() { - private val _shieldModeActive = MutableStateFlow(null) - val shieldModeActive: StateFlow = _shieldModeActive.asStateFlow() - - val roomState: RoomState? get() = channelRepository.getRoomState(channel) - val isBroadcaster: Boolean get() = authDataStore.userIdString == roomState?.channelId + val shieldModeActive: StateFlow = shieldModeRepository.getState(channel) + val roomState: RoomState? = channelRepository.getRoomState(channel) + val isBroadcaster: Boolean = authDataStore.userIdString == roomState?.channelId init { - fetchShieldMode() - } - - private fun fetchShieldMode() { viewModelScope.launch { - val channelId = channelRepository.getChannel(channel)?.id ?: return@launch - val moderatorId = authDataStore.userIdString ?: return@launch - helixApiClient - .getShieldMode(channelId, moderatorId) - .onSuccess { _shieldModeActive.value = it.isActive } + shieldModeRepository.fetch(channel) } } } From 2de01026d6d79faef53246cd49e06562389a64e0 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 23:15:36 +0200 Subject: [PATCH 286/349] refactor(chat): Replace ClickableText with LinkableText, add configurable inline spacers for consistent timestamp gap --- .../ui/chat/messages/AutomodMessage.kt | 5 +- .../dankchat/ui/chat/messages/PrivMessage.kt | 3 +- .../ui/chat/messages/SystemMessages.kt | 22 ++---- .../ui/chat/messages/WhisperAndRedemption.kt | 8 +- .../ui/chat/messages/common/LinkableText.kt | 74 +++++++++++++++++++ .../messages/common/MessageTextRenderer.kt | 13 ++++ .../messages/common/SimpleMessageContainer.kt | 17 ++--- .../common/TextWithMeasuredInlineContent.kt | 13 ++++ 8 files changed, 121 insertions(+), 34 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/LinkableText.kt 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 index 56dead24e..9c2c261b7 100644 --- 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 @@ -30,6 +30,7 @@ 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 @@ -101,7 +102,7 @@ fun AutomodMessageComposable( ) { append(message.timestamp) } - append("\u2009") + appendInlineSpacer(6.dp) } // Badges @@ -188,7 +189,7 @@ fun AutomodMessageComposable( ) { append(message.timestamp) } - append("\u2009") + appendInlineSpacer(6.dp) } // Username in bold with user color 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 index 0177f884c..6ebee28e3 100644 --- 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 @@ -41,6 +41,7 @@ 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 @@ -220,7 +221,7 @@ private fun PrivMessageText( withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { append(message.timestamp) } - append("\u2009") + appendInlineSpacer(6.dp) } // Badges (using appendInlineContent for proper rendering) 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 index 71cf435db..c5a05b45f 100644 --- 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 @@ -5,9 +5,7 @@ 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.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember @@ -22,12 +20,11 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.LocalPlatformContext 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.URL_ANNOTATION_TAG +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.rememberAdaptiveLinkColor import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveTextColor import com.flxrs.dankchat.ui.chat.messages.common.rememberBackgroundColor @@ -87,7 +84,6 @@ fun UserNoticeMessageComposable( modifier: Modifier = Modifier, highlightShape: Shape = RectangleShape, ) { - val context = LocalPlatformContext.current val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) val textColor = rememberAdaptiveTextColor(bgColor) val linkColor = rememberAdaptiveLinkColor(bgColor) @@ -103,7 +99,7 @@ fun UserNoticeMessageComposable( withStyle(timestampSpanStyle(textSize.value, timestampColor)) { append(message.timestamp) } - append("\u2009") + appendInlineSpacer(6.dp) } // Message text with colored display name @@ -157,16 +153,10 @@ fun UserNoticeMessageComposable( .background(bgColor, highlightShape) .padding(horizontal = 6.dp, vertical = 3.dp), ) { - ClickableText( + LinkableText( text = annotatedString, style = TextStyle(fontSize = textSize), modifier = Modifier.fillMaxWidth(), - onClick = { offset -> - val url = annotatedString.getStringAnnotations(URL_ANNOTATION_TAG, offset, offset).firstOrNull() - if (url != null) { - launchCustomTab(context, url.item) - } - }, ) } } @@ -279,7 +269,7 @@ fun ModerationMessageComposable( withStyle(timestampSpanStyle(textSize.value, timestampColor)) { append(message.timestamp) } - append("\u2009") + appendInlineSpacer(6.dp) } // Render message: highlighted ranges at full opacity, template text dimmed @@ -313,7 +303,7 @@ fun ModerationMessageComposable( .background(bgColor) .padding(horizontal = 6.dp, vertical = 3.dp), ) { - Text( + 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 index 7b27d8a42..8ba308f81 100644 --- 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 @@ -38,7 +38,9 @@ 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.launchCustomTab import com.flxrs.dankchat.ui.chat.messages.common.parseUserAnnotation @@ -128,7 +130,7 @@ private fun WhisperMessageText( withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { append(message.timestamp) } - append("\u2009") + appendInlineSpacer(6.dp) } // Badges (using appendInlineContent for proper rendering) @@ -270,7 +272,7 @@ fun PointRedemptionMessageComposable( withStyle(timestampSpanStyle(fontSize, textColor)) { append(message.timestamp) } - append("\u2009") + appendInlineSpacer(6.dp) } when { @@ -293,7 +295,7 @@ fun PointRedemptionMessageComposable( } } - BasicText( + LinkableText( text = annotatedString, style = TextStyle(fontSize = fontSize.sp, color = textColor), ) 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/MessageTextRenderer.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextRenderer.kt index 18ad6a030..97d2300e1 100644 --- 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 @@ -5,6 +5,7 @@ import android.util.Log 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 @@ -15,6 +16,8 @@ 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 @@ -138,3 +141,13 @@ fun timestampSpanStyle( 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 index fb143895d..2cfb07434 100644 --- 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 @@ -5,7 +5,6 @@ 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.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -16,9 +15,9 @@ 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 -import coil3.compose.LocalPlatformContext @Composable fun SimpleMessageContainer( @@ -29,20 +28,20 @@ fun SimpleMessageContainer( darkBackgroundColor: Color, textAlpha: Float, modifier: Modifier = Modifier, + timestampSpacerWidth: Dp = 6.dp, ) { - val context = LocalPlatformContext.current 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) { + remember(message, timestamp, textColor, linkColor, timestampColor, fontSize, timestampSpacerWidth) { buildAnnotatedString { withStyle(timestampSpanStyle(fontSize.value, timestampColor)) { append(timestamp) } - append(" ") + appendInlineSpacer(timestampSpacerWidth) withStyle(SpanStyle(color = textColor)) { appendWithLinks(message, linkColor) } @@ -58,16 +57,10 @@ fun SimpleMessageContainer( .background(bgColor) .padding(horizontal = 6.dp, vertical = 3.dp), ) { - ClickableText( + LinkableText( text = annotatedString, style = TextStyle(fontSize = fontSize), modifier = Modifier.fillMaxWidth(), - onClick = { offset -> - val url = annotatedString.getStringAnnotations(URL_ANNOTATION_TAG, offset, offset).firstOrNull() - if (url != null) { - launchCustomTab(context, url.item) - } - }, ) } } 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 index 9e2151936..99635559e 100644 --- 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 @@ -20,6 +20,7 @@ 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.dp import androidx.compose.ui.unit.sp import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -54,6 +55,16 @@ fun TextWithMeasuredInlineContent( // Add all pre-known dimensions first 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) { @@ -161,3 +172,5 @@ fun TextWithMeasuredInlineContent( } } } + +private const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineContent" From 5bfe45f1d11e2009348d9d996908a5877412b67a Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 3 Apr 2026 23:49:10 +0200 Subject: [PATCH 287/349] fix(ui): Add paste action to message copy snackbar via MainEventBus --- .../com/flxrs/dankchat/ui/main/MainEvent.kt | 6 ++++++ .../com/flxrs/dankchat/ui/main/MainScreen.kt | 1 - .../ui/main/MainScreenEventHandler.kt | 18 ++++++++++++++++++ .../ui/main/dialog/MainScreenDialogs.kt | 19 ++++++------------- 4 files changed, 30 insertions(+), 14 deletions(-) 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 index 03cead045..36fdb9891 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt @@ -25,4 +25,10 @@ sealed interface 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/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt index fe98ce441..b2e799cf0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -273,7 +273,6 @@ fun MainScreen( modActionsChannel = inputState.activeChannel, isStreamActive = currentStream != null, inputSheetState = inputSheetState, - snackbarHostState = snackbarHostState, sheetsReady = sheetsReady, onAddChannel = { channelManagementViewModel.addChannel(it) 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 index 33277c2d7..3f85f6593 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -79,6 +79,24 @@ fun MainScreenEventHandler( 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() 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 index 6e4b282cc..836243653 100644 --- 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 @@ -12,7 +12,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -44,6 +43,8 @@ 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 @@ -69,7 +70,6 @@ fun MainScreenDialogs( modActionsChannel: UserName?, isStreamActive: Boolean, inputSheetState: InputSheetState, - snackbarHostState: SnackbarHostState, sheetsReady: Boolean, onAddChannel: (UserName) -> Unit, onLogout: () -> Unit, @@ -79,10 +79,6 @@ fun MainScreenDialogs( onJumpToMessage: (messageId: String, channel: UserName) -> Unit = { _, _ -> }, ) { val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() - val clipboardManager = LocalClipboard.current - val scope = rememberCoroutineScope() - val messageCopiedMsg = stringResource(R.string.snackbar_message_copied) - val messageIdCopiedMsg = stringResource(R.string.snackbar_message_id_copied) val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() @@ -211,7 +207,6 @@ fun MainScreenDialogs( dialogState.messageOptionsParams?.let { params -> MessageOptionsDialogContainer( params = params, - snackbarHostState = snackbarHostState, onJumpToMessage = onJumpToMessage, onSetReplying = chatInputViewModel::setReplying, onOpenReplies = sheetNavigationViewModel::openReplies, @@ -367,7 +362,6 @@ private fun ModActionsDialogContainer( @Composable private fun MessageOptionsDialogContainer( params: MessageOptionsParams, - snackbarHostState: SnackbarHostState, onJumpToMessage: (String, UserName) -> Unit, onSetReplying: (Boolean, String, UserName, String) -> Unit, onOpenReplies: (String, UserName) -> Unit, @@ -380,9 +374,8 @@ private fun MessageOptionsDialogContainer( ) val state by viewModel.state.collectAsStateWithLifecycle() val clipboardManager = LocalClipboard.current + val mainEventBus: MainEventBus = koinInject() val scope = rememberCoroutineScope() - val messageCopiedMsg = stringResource(R.string.snackbar_message_copied) - val messageIdCopiedMsg = stringResource(R.string.snackbar_message_id_copied) (state as? MessageOptionsState.Found)?.let { s -> MessageOptionsDialog( @@ -403,19 +396,19 @@ private fun MessageOptionsDialogContainer( onCopy = { scope.launch { clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", s.originalMessage))) - snackbarHostState.showSnackbar(messageCopiedMsg) + mainEventBus.emitEvent(MainEvent.MessageCopied(s.originalMessage)) } }, onCopyFullMessage = { scope.launch { clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", params.fullMessage))) - snackbarHostState.showSnackbar(messageCopiedMsg) + mainEventBus.emitEvent(MainEvent.MessageCopied(params.fullMessage)) } }, onCopyMessageId = { scope.launch { clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", s.messageId))) - snackbarHostState.showSnackbar(messageIdCopiedMsg) + mainEventBus.emitEvent(MainEvent.MessageIdCopied) } }, onDelete = viewModel::deleteMessage, From 1f7191b33698fc2ed343c84520124f143a98d637 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 00:27:57 +0200 Subject: [PATCH 288/349] chore: Migrate to detekt 2.x snapshot with type resolution support --- app/build.gradle.kts | 6 ++++++ app/config/detekt.yml | 2 +- .../preferences/components/PreferenceCategory.kt | 4 ++-- .../dankchat/preferences/components/PreferenceItem.kt | 2 +- .../preferences/overview/OverviewSettingsScreen.kt | 2 +- gradle/libs.versions.toml | 6 +++--- settings.gradle.kts | 9 +++++++++ 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 14b31e034..f1a39d6f8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -264,6 +264,12 @@ detekt { parallel = true } +tasks.withType().configureEach { + exclude { + it.file.absolutePath.contains("/build/generated/") + } +} + fun gradleLocalProperties( projectRootDir: File, providers: ProviderFactory, diff --git a/app/config/detekt.yml b/app/config/detekt.yml index 76a1024e4..829d7e6fe 100644 --- a/app/config/detekt.yml +++ b/app/config/detekt.yml @@ -16,7 +16,7 @@ complexity: LargeClass: active: false ComplexCondition: - threshold: 5 + allowedConditions: 4 naming: FunctionNaming: 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 f0c3cf8be..8b32bb1f8 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 @@ -58,7 +58,7 @@ fun PreferenceCategoryTitle( ) } -@Suppress("UnusedPrivateMember") +@Suppress("UnusedPrivateFunction") @Composable @PreviewLightDark private fun PreferenceCategoryPreview( @@ -74,7 +74,7 @@ private fun PreferenceCategoryPreview( } } -@Suppress("UnusedPrivateMember") +@Suppress("UnusedPrivateFunction") @Composable @PreviewLightDark private fun PreferenceCategoryWithItemsPreview( 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 410d2a3fa..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 @@ -283,7 +283,7 @@ private fun RowScope.PreferenceItemContent( } } -@Suppress("UnusedPrivateMember") +@Suppress("UnusedPrivateFunction") @Composable @PreviewLightDark private fun PreferenceItemPreview() { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt index ff60fbba5..ee89557c7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -174,7 +174,7 @@ fun OverviewSettingsScreen( } } -@Suppress("UnusedPrivateMember") +@Suppress("UnusedPrivateFunction") @Composable @PreviewDynamicColors @PreviewLightDark diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 312b43168..dd4506799 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,8 +47,8 @@ reorderable = "2.4.3" spotless = "8.4.0" ktlint = "1.8.0" -detekt = "1.23.8" -composeRules = "0.4.23" +detekt = "main-SNAPSHOT" # TODO switch to stable alpha.3+ when released +composeRules = "0.5.6" junit = "6.0.3" androidJunit5 = "2.0.1" @@ -160,5 +160,5 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 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 = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +detekt = { id = "dev.detekt", version.ref = "detekt" } androidx-room = { id = "androidx.room", version.ref = "androidxRoom" } #TODO use me when working diff --git a/settings.gradle.kts b/settings.gradle.kts index e66312d6e..c253021b6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,14 @@ pluginManagement { 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 { @@ -15,6 +23,7 @@ dependencyResolutionManagement { google() mavenCentral() maven(url = "https://jitpack.io") + maven(url = "https://central.sonatype.com/repository/maven-snapshots/") } } From 229ca323ba3ebd61afaecf354f67d3711a69223a Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 11:13:44 +0200 Subject: [PATCH 289/349] =?UTF-8?q?fix:=20Resolve=20all=20detekt=20type-re?= =?UTF-8?q?solution=20findings=20=E2=80=94=20inject=20dispatchers,=20fix?= =?UTF-8?q?=20locale,=20sealed=20interface,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config/detekt.yml | 4 ++ .../data/api/eventapi/EventSubClient.kt | 2 +- .../dankchat/data/api/upload/UploadClient.kt | 7 ++-- .../dankchat/data/debug/ApiDebugSection.kt | 2 +- .../dankchat/data/debug/AppDebugSection.kt | 5 ++- .../data/notification/ChatTTSPlayer.kt | 5 ++- .../data/notification/NotificationService.kt | 6 ++- .../dankchat/data/repo/IgnoresRepository.kt | 4 +- .../data/repo/channel/ChannelRepository.kt | 7 ++-- .../data/repo/chat/ChatMessageRepository.kt | 4 +- .../data/repo/chat/RecentMessagesHandler.kt | 5 ++- .../data/repo/command/CommandRepository.kt | 4 +- .../dankchat/data/repo/data/DataRepository.kt | 26 ++++++------- .../data/repo/emote/EmoteRepository.kt | 38 ++++++++++--------- .../data/twitch/message/AutomodMessage.kt | 2 +- .../dankchat/data/twitch/message/Message.kt | 12 +++--- .../data/twitch/message/ModerationMessage.kt | 7 ++-- .../data/twitch/message/NoticeMessage.kt | 2 +- .../twitch/message/PointRedemptionMessage.kt | 7 ++-- .../data/twitch/message/PrivMessage.kt | 12 +++--- .../data/twitch/message/SystemMessage.kt | 2 +- .../data/twitch/message/UserNoticeMessage.kt | 8 ++-- .../data/twitch/message/WhisperMessage.kt | 9 +++-- .../data/twitch/pubsub/PubSubManager.kt | 4 +- .../com/flxrs/dankchat/di/CoroutineModule.kt | 1 + .../dankchat/preferences/about/AboutScreen.kt | 6 ++- .../chat/userdisplay/UserDisplayViewModel.kt | 5 ++- .../components/PreferenceCategory.kt | 2 +- .../components/PreferenceTabRow.kt | 5 +-- .../highlights/HighlightsScreen.kt | 8 ++-- .../highlights/HighlightsViewModel.kt | 5 ++- .../notifications/ignores/IgnoreItem.kt | 2 +- .../notifications/ignores/IgnoresScreen.kt | 8 ++-- .../notifications/ignores/IgnoresViewModel.kt | 5 ++- .../tools/tts/TTSUserIgnoreListViewModel.kt | 2 +- .../dankchat/ui/changelog/DankChatVersion.kt | 2 +- .../dankchat/ui/chat/ChatMessageMapper.kt | 2 +- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 5 ++- .../chat/history/MessageHistoryViewModel.kt | 7 ++-- .../ui/chat/mention/MentionViewModel.kt | 7 ++-- .../dankchat/ui/chat/messages/PrivMessage.kt | 2 +- .../ui/chat/replies/RepliesViewModel.kt | 5 ++- .../flxrs/dankchat/ui/main/MainActivity.kt | 11 +++--- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 2 - .../ui/main/dialog/ManageChannelsDialog.kt | 2 +- .../ui/main/input/ChatInputViewModel.kt | 2 +- .../ui/main/sheet/EmoteMenuViewModel.kt | 9 +++-- .../dankchat/ui/share/ShareUploadActivity.kt | 7 ++-- .../utils/extensions/CollectionExtensions.kt | 1 + 49 files changed, 165 insertions(+), 132 deletions(-) diff --git a/app/config/detekt.yml b/app/config/detekt.yml index 829d7e6fe..b8a923210 100644 --- a/app/config/detekt.yml +++ b/app/config/detekt.yml @@ -46,6 +46,10 @@ style: LoopWithTooManyJumpStatements: maxJumpCount: 3 +potential-bugs: + IgnoredReturnValue: + active: false + exceptions: TooGenericExceptionCaught: active: false 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 1ba867071..620be9ad9 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 @@ -336,7 +336,7 @@ class EventSubClient( private fun handleRevocation(message: RevocationMessageDto) { Log.i(TAG, "[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) { 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 0c9b67a62..d7b4d5cbe 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 @@ -4,12 +4,12 @@ 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.DispatchersProvider import com.flxrs.dankchat.di.UploadOkHttpClient import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore 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 @@ -36,8 +36,9 @@ import java.time.Instant class UploadClient( @Named(type = UploadOkHttpClient::class) 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) @@ -104,7 +105,7 @@ class UploadClient( } @Suppress("RegExpRedundantEscape") - private suspend fun JsonElement.extractLink(linkPattern: String): String = withContext(Dispatchers.Default) { + private suspend fun JsonElement.extractLink(linkPattern: String): String = withContext(dispatchersProvider.default) { extractJsonLink(linkPattern) } 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 index 47b5ff3be..58c1d4dbc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt @@ -26,7 +26,7 @@ class ApiDebugSection( val statusCounts = helixApiStats.statusCounts .entries - .sortedBy { it.key } + .sortedBy { entry -> entry.key } .map { (code, count) -> DebugEntry("HTTP $code", "$count") } DebugSectionSnapshot( 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 index c138e9893..3fae5dec0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt @@ -6,6 +6,7 @@ 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 { @@ -58,11 +59,11 @@ class AppDebugSection : DebugSection { private fun formatBytes(bytes: Long): String { val mb = bytes / (1024.0 * 1024.0) - return "%.1f MB".format(mb) + return "%.1f MB".format(Locale.ROOT, mb) } private fun formatKb(kb: Long): String { val mb = kb / 1024.0 - return "%.1f MB".format(mb) + return "%.1f MB".format(Locale.ROOT, mb) } } 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 index cf14ed6ae..ce84745db 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt @@ -15,6 +15,7 @@ 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 @@ -22,7 +23,6 @@ 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.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow @@ -38,8 +38,9 @@ class ChatTTSPlayer( private val chatChannelProvider: ChatChannelProvider, private val toolsSettingsDataStore: ToolsSettingsDataStore, private val appLifecycleListener: AppLifecycleListener, + private val dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) private var tts: TextToSpeech? = null private var audioManager: AudioManager? = 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 6144af9cf..1ab5bc50a 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 @@ -16,13 +16,13 @@ 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.repo.data.DataRepository +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.ui.main.MainActivity import com.flxrs.dankchat.utils.AppLifecycleListener import com.flxrs.dankchat.utils.AppLifecycleListener.AppLifecycle import io.ktor.util.collections.ConcurrentSet import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.combine @@ -48,11 +48,13 @@ class NotificationService : private val dataRepository: DataRepository by inject() private val notificationsSettingsDataStore: NotificationsSettingsDataStore by inject() private val appLifecycleListener: AppLifecycleListener by inject() + private val dispatchersProvider: DispatchersProvider by inject() private val pendingIntentFlag: Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT private val job = SupervisorJob() - override val coroutineContext: CoroutineContext = Dispatchers.IO + job + override val coroutineContext: CoroutineContext + get() = dispatchersProvider.io + job inner class LocalBinder( val service: NotificationService = this@NotificationService, 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 8385e337f..f64c7c939 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 @@ -42,7 +42,7 @@ class IgnoresRepository( 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) @@ -101,7 +101,7 @@ class IgnoresRepository( 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 } 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 221f84f8a..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 @@ -10,9 +10,9 @@ 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.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 @@ -25,6 +25,7 @@ class ChannelRepository( private val usersRepository: UsersRepository, private val helixApiClient: HelixApiClient, private val authDataStore: AuthDataStore, + private val dispatchersProvider: DispatchersProvider, ) { private val channelCache = ConcurrentHashMap() private val roomStates = ConcurrentHashMap() @@ -108,7 +109,7 @@ class ChannelRepository( flow.tryEmit(state) } - suspend fun getChannelsByIds(ids: 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 } @@ -127,7 +128,7 @@ class ChannelRepository( return@withContext cached + channels } - suspend fun getChannels(names: Collection): List = withContext(Dispatchers.IO) { + 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 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 index e4fcea70d..3e08f8d28 100644 --- 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 @@ -35,8 +35,8 @@ import kotlin.concurrent.atomics.plusAssign class ChatMessageRepository( private val messageProcessor: MessageProcessor, private val chatNotificationRepository: ChatNotificationRepository, + private val dispatchersProvider: DispatchersProvider, chatSettingsDataStore: ChatSettingsDataStore, - dispatchersProvider: DispatchersProvider, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val messages = ConcurrentHashMap>>() @@ -140,7 +140,7 @@ class ChatMessageRepository( } } - suspend fun reparseAllEmotesAndBadges() = withContext(Dispatchers.Default) { + suspend fun reparseAllEmotesAndBadges() = withContext(dispatchersProvider.default) { messages.values .map { flow -> async { 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 index 4e2133acc..257eae6d9 100644 --- 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 @@ -20,10 +20,10 @@ 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.ktor.util.collections.ConcurrentSet -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.koin.core.annotation.Single @@ -35,6 +35,7 @@ class RecentMessagesHandler( private val messageProcessor: MessageProcessor, private val chatMessageRepository: ChatMessageRepository, private val usersRepository: UsersRepository, + private val dispatchersProvider: DispatchersProvider, ) { private val loadedChannels = ConcurrentSet() @@ -47,7 +48,7 @@ class RecentMessagesHandler( suspend fun load( channel: UserName, isReconnect: Boolean = false, - ): Result = withContext(Dispatchers.IO) { + ): Result = withContext(dispatchersProvider.io) { if (!isReconnect && channel in loadedChannels) { return@withContext Result(emptyList(), emptyList()) } 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 0cf473116..0f8f79f03 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 @@ -50,7 +50,7 @@ class CommandRepository( private val chatSettingsDataStore: ChatSettingsDataStore, private val developerSettingsDataStore: DeveloperSettingsDataStore, private val authDataStore: AuthDataStore, - dispatchersProvider: DispatchersProvider, + private val dispatchersProvider: DispatchersProvider, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val customCommands = chatSettingsDataStore.commands.stateIn(scope, SharingStarted.Eagerly, emptyList()) @@ -174,7 +174,7 @@ class CommandRepository( } } - suspend fun loadSupibotCommands() = withContext(Dispatchers.Default) { + suspend fun loadSupibotCommands() = withContext(dispatchersProvider.default) { if (!authDataStore.isLoggedIn || SuggestionType.SupibotCommands !in chatSettingsDataStore.settings.first().suggestionTypes) { return@withContext } 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 afd318fd3..9f8907f9e 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 @@ -56,7 +56,7 @@ class DataRepository( private val recentUploadsRepository: RecentUploadsRepository, 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()) @@ -132,7 +132,7 @@ class DataRepository( it.imageLink } - suspend fun loadGlobalBadges(): Result = withContext(Dispatchers.IO) { + suspend fun loadGlobalBadges(): Result = withContext(dispatchersProvider.io) { measureTimeAndLog(TAG, "global badges") { val result = when { @@ -144,7 +144,7 @@ class DataRepository( } } - suspend fun loadDankChatBadges(): Result = withContext(Dispatchers.IO) { + suspend fun loadDankChatBadges(): Result = withContext(dispatchersProvider.io) { measureTimeAndLog(TAG, "DankChat badges") { dankChatApiClient .getDankChatBadges() @@ -168,7 +168,7 @@ class DataRepository( suspend fun loadChannelBadges( channel: UserName, id: UserId, - ): Result = withContext(Dispatchers.IO) { + ): Result = withContext(dispatchersProvider.io) { measureTimeAndLog(TAG, "channel badges for #$id") { val result = when { @@ -183,7 +183,7 @@ class DataRepository( suspend fun loadChannelFFZEmotes( channel: UserName, channelId: UserId, - ): Result = withContext(Dispatchers.IO) { + ): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { return@withContext Result.success(Unit) } @@ -192,7 +192,7 @@ class DataRepository( ffzApiClient .getFFZChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } - .onSuccess { it?.let { emoteRepository.setFFZEmotes(channel, it) } } + .onSuccess { emotes -> emotes?.let { emoteRepository.setFFZEmotes(channel, it) } } .map { } } } @@ -201,7 +201,7 @@ class DataRepository( channel: UserName, channelDisplayName: DisplayName, channelId: UserId, - ): Result = withContext(Dispatchers.IO) { + ): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { return@withContext Result.success(Unit) } @@ -210,7 +210,7 @@ class DataRepository( bttvApiClient .getBTTVChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } - .onSuccess { it?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } + .onSuccess { emotes -> emotes?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } .map { } } } @@ -218,7 +218,7 @@ class DataRepository( suspend fun loadChannelSevenTVEmotes( channel: UserName, channelId: UserId, - ): Result = withContext(Dispatchers.IO) { + ): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { return@withContext Result.success(Unit) } @@ -241,7 +241,7 @@ class DataRepository( suspend fun loadChannelCheermotes( channel: UserName, channelId: UserId, - ): Result = withContext(Dispatchers.IO) { + ): Result = withContext(dispatchersProvider.io) { if (!authDataStore.isLoggedIn) { return@withContext Result.success(Unit) } @@ -255,7 +255,7 @@ class DataRepository( } } - suspend fun loadGlobalFFZEmotes(): Result = withContext(Dispatchers.IO) { + suspend fun loadGlobalFFZEmotes(): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { return@withContext Result.success(Unit) } @@ -269,7 +269,7 @@ class DataRepository( } } - suspend fun loadGlobalBTTVEmotes(): Result = withContext(Dispatchers.IO) { + suspend fun loadGlobalBTTVEmotes(): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { return@withContext Result.success(Unit) } @@ -283,7 +283,7 @@ class DataRepository( } } - suspend fun loadGlobalSevenTVEmotes(): Result = withContext(Dispatchers.IO) { + suspend fun loadGlobalSevenTVEmotes(): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { return@withContext Result.success(Unit) } 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 09af12a15..4443b94f7 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 @@ -44,13 +44,13 @@ 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.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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -58,6 +58,7 @@ 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 @@ -66,6 +67,7 @@ class EmoteRepository( private val helixApiClient: HelixApiClient, private val chatSettingsDataStore: ChatSettingsDataStore, private val channelRepository: ChannelRepository, + private val dispatchersProvider: DispatchersProvider, ) { private val ffzModBadges = ConcurrentHashMap() private val ffzVipBadges = ConcurrentHashMap() @@ -440,7 +442,7 @@ class EmoteRepository( private suspend fun loadUserEmotesViaHelix( userId: UserId, onFirstPageLoaded: (() -> Unit)? = null, - ) = withContext(Dispatchers.Default) { + ) = withContext(dispatchersProvider.default) { val seenIds = mutableSetOf() val allEmotes = mutableListOf() var totalCount = 0 @@ -519,7 +521,7 @@ class EmoteRepository( suspend fun setFFZEmotes( channel: UserName, ffzResult: FFZChannelDto, - ) = withContext(Dispatchers.Default) { + ) = withContext(dispatchersProvider.default) { val ffzEmotes = ffzResult.sets .flatMap { set -> @@ -540,7 +542,7 @@ class EmoteRepository( } } - suspend fun setFFZGlobalEmotes(ffzResult: FFZGlobalDto) = withContext(Dispatchers.Default) { + suspend fun setFFZGlobalEmotes(ffzResult: FFZGlobalDto) = withContext(dispatchersProvider.default) { val ffzGlobalEmotes = ffzResult.sets .filter { it.key in ffzResult.defaultSets } @@ -556,14 +558,14 @@ class EmoteRepository( channel: UserName, channelDisplayName: DisplayName, bttvResult: BTTVChannelDto, - ) = withContext(Dispatchers.Default) { + ) = withContext(dispatchersProvider.default) { val bttvEmotes = (bttvResult.emotes + bttvResult.sharedEmotes).map { parseBTTVEmote(it, channelDisplayName) } 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) } globalEmoteState.update { it.copy(bttvEmotes = bttvGlobalEmotes) } } @@ -571,7 +573,7 @@ class EmoteRepository( suspend fun setSevenTVEmotes( channel: UserName, userDto: SevenTVUserDto, - ) = withContext(Dispatchers.Default) { + ) = withContext(dispatchersProvider.default) { val emoteSetId = userDto.emoteSet?.id ?: return@withContext val emoteList = userDto.emoteSet.emotes.orEmpty() @@ -596,7 +598,7 @@ class EmoteRepository( suspend fun setSevenTVEmoteSet( channel: UserName, emoteSet: SevenTVEmoteSetDto, - ) = withContext(Dispatchers.Default) { + ) = withContext(dispatchersProvider.default) { sevenTvChannelDetails[channel]?.let { details -> sevenTvChannelDetails[channel] = details.copy(activeEmoteSetId = emoteSet.id) } @@ -617,7 +619,7 @@ class EmoteRepository( suspend fun updateSevenTVEmotes( channel: UserName, event: SevenTVEventMessage.EmoteSetUpdated, - ) = withContext(Dispatchers.Default) { + ) = withContext(dispatchersProvider.default) { val addedEmotes = event.added .filterUnlistedIfEnabled() @@ -648,7 +650,7 @@ class EmoteRepository( } } - suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = withContext(Dispatchers.Default) { + suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = withContext(dispatchersProvider.default) { if (sevenTvResult.isEmpty()) return@withContext val sevenTvGlobalEmotes = @@ -664,7 +666,7 @@ class EmoteRepository( suspend fun setCheermotes( channel: UserName, cheermoteDtos: List, - ) = withContext(Dispatchers.Default) { + ) = withContext(dispatchersProvider.default) { val cheermoteSets = cheermoteDtos.map { dto -> CheermoteSet( @@ -766,8 +768,8 @@ class EmoteRepository( } 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, @@ -872,7 +874,7 @@ class EmoteRepository( 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, @@ -888,8 +890,8 @@ class EmoteRepository( ): 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, @@ -904,8 +906,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, 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 index f7525eeb3..18fe3b9cb 100644 --- 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 @@ -19,6 +19,6 @@ data class AutomodMessage( val color: Int? = null, val status: Status = Status.Pending, val isUserSide: Boolean = false, -) : Message() { +) : Message { enum class Status { Pending, Approved, Denied, Expired } } 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 3ec688efd..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,10 +5,10 @@ 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, @@ -23,8 +23,8 @@ sealed class Message { val badgeInfoTag: String?, ) - open val emoteData: EmoteData? = null - open val badgeData: BadgeData? = null + val emoteData: EmoteData? get() = null + val badgeData: BadgeData? get() = null companion object { private const val DEFAULT_COLOR_TAG = "#717171" 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 fec5a6324..6ccaf022c 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 @@ -35,7 +35,7 @@ data class ModerationMessage( val reason: String? = null, val fromEventSource: Boolean = false, val stackCount: Int = 0, -) : Message() { +) : Message { enum class Action { Timeout, Untimeout, @@ -94,12 +94,12 @@ data class ModerationMessage( ): TextResource { val creator = creatorUserDisplay.toString() val target = targetUserDisplay.toString() - val dur: Any = duration ?: "" val source = sourceBroadcasterDisplay.toString() val message = when (action) { Action.Timeout -> { + val dur = duration ?: TextResource.Plain("") when (targetUser) { currentUser -> { when (creatorUserDisplay) { @@ -279,6 +279,7 @@ data class ModerationMessage( } Action.SharedTimeout -> { + val dur = duration ?: TextResource.Plain("") 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)) @@ -400,7 +401,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"] ?: "clearmsg-$ts-$channel-${target ?: ""}" + val id = tags["id"] ?: "clearmsg-$ts-$channel-${target.orEmpty()}" return ModerationMessage( timestamp = ts, 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 28b37e879..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 @@ -12,7 +12,7 @@ data class NoticeMessage( override val highlights: Set = emptySet(), val channel: UserName, val message: String, -) : Message() { +) : Message { companion object { fun parseNotice(message: IrcMessage): NoticeMessage = with(message) { val channel = params[0].substring(1) 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 23e7528b8..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,7 +20,7 @@ data class PointRedemptionMessage( val cost: Int, val requiresUserInput: Boolean, val userDisplay: UserDisplay? = null, -) : Message() { +) : Message { companion object { fun parsePointReward( timestamp: Instant, @@ -35,8 +35,9 @@ data class PointRedemptionMessage( title = data.reward.effectiveTitle, rewardImageUrl = data.reward.images?.imageLarge - ?: data.reward.defaultImages?.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 8e12dc360..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 @@ -10,6 +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.data.twitch.message.Message.Companion.parseEmoteTag +import java.util.Locale import java.util.UUID data class PrivMessage( @@ -35,14 +37,14 @@ data class PrivMessage( val rewardCost: Int? = null, val rewardTitle: String? = null, val rewardImageUrl: String? = null, - override val emoteData: EmoteData = - EmoteData( + override val emoteData: Message.EmoteData = + Message.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() { + override val badgeData: Message.BadgeData = Message.BadgeData(userId, channel, badgeTag = tags["badges"], badgeInfoTag = tags["badge-info"]), +) : Message { companion object { fun parsePrivMessage( ircMessage: IrcMessage, @@ -134,7 +136,7 @@ val PrivMessage.hypeChatInfo: String? 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(amount / divisor) + val formatted = "%.2f".format(Locale.getDefault(), amount / divisor) return "Hype Chat Level $level — $formatted $currency" } 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 61a7a5cac..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() +) : Message 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 b96104a0e..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,9 +14,9 @@ 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 = @@ -53,7 +53,7 @@ data class UserNoticeMessage( val id = tags["id"] ?: UUID.randomUUID().toString() val channel = params[0].substring(1) - val defaultMessage = tags["system-msg"] ?: "" + val defaultMessage = tags["system-msg"].orEmpty() val systemMsg = when { msgId == "announcement" -> { 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 1292136a8..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 @@ -10,6 +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.message.Message.Companion.parseEmoteTag import java.util.UUID data class WhisperMessage( @@ -33,9 +34,9 @@ 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() @@ -48,7 +49,7 @@ data class WhisperMessage( val displayName = tags["display-name"] ?: name val color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) val recipientColor = recipientColorTag?.let(Color::parseColor) - val emoteTag = tags["emotes"] ?: "" + val emoteTag = tags["emotes"].orEmpty() val message = params.getOrElse(1) { "" } return WhisperMessage( 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 26a4f5474..f88fdd5be 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 @@ -124,7 +124,7 @@ class PubSubManager( .chunked(PubSubConnection.MAX_TOPICS) .withIndex() .takeWhile { (idx, _) -> connections.size + idx + 1 <= PubSubConnection.MAX_CONNECTIONS } - .forEach { (_, topics) -> + .forEach { (_, chunk) -> val connection = PubSubConnection( tag = "#${connections.size}", @@ -133,7 +133,7 @@ class PubSubManager( oAuth = oAuth, jsonFormat = json, ) - connection.connect(initialTopics = topics.toSet()) + connection.connect(initialTopics = chunk.toSet()) connections += connection collectJobs += launch { connection.collectEvents() } } 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/preferences/about/AboutScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt index 8055a1ad3..f426a5913 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -39,6 +39,7 @@ 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 @@ -46,13 +47,14 @@ 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 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), @@ -77,7 +79,7 @@ fun AboutScreen(onBack: () -> Unit) { val libraries = produceState(null) { value = - withContext(Dispatchers.IO) { + withContext(dispatchersProvider.io) { Libs.Builder().withContext(context).build() } } 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 5ec91bf05..dccdecc50 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,8 +4,8 @@ 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 @@ -15,6 +15,7 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class UserDisplayViewModel( private val userDisplayRepository: UserDisplayRepository, + private val dispatchersProvider: DispatchersProvider, ) : ViewModel() { private val eventChannel = Channel(Channel.BUFFERED) @@ -59,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/PreferenceCategory.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceCategory.kt index 8b32bb1f8..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 @@ -74,7 +74,7 @@ private fun PreferenceCategoryPreview( } } -@Suppress("UnusedPrivateFunction") +@Suppress("UnusedPrivateFunction", "UnusedParameter") @Composable @PreviewLightDark private fun PreferenceCategoryWithItemsPreview( 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/notifications/highlights/HighlightsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsScreen.kt index e67a55e9f..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 @@ -79,6 +79,7 @@ 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 @@ -86,9 +87,9 @@ 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 @@ -132,6 +133,7 @@ private fun HighlightsScreen( onPageChange: (Int) -> Unit, onNavBack: () -> Unit, ) { + val dispatchersProvider = koinInject() val focusManager = LocalFocusManager.current val snackbarHost = remember { SnackbarHostState() } val pagerState = rememberPagerState { HighlightsTab.entries.size } @@ -150,7 +152,7 @@ private fun HighlightsScreen( LaunchedEffect(eventsWrapper) { eventsWrapper.events - .flowOn(Dispatchers.Main.immediate) + .flowOn(dispatchersProvider.immediate) .collectLatest { event -> focusManager.clearFocus() when (event) { @@ -245,7 +247,7 @@ private fun HighlightsScreen( textAlign = TextAlign.Center, ) PreferenceTabRow( - appBarContainerColor = appBarContainerColor, + appBarContainerColor = appBarContainerColor.value, pagerState = pagerState, tabCount = HighlightsTab.entries.size, tabText = { 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 b5d13121a..67acc592d 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 @@ -23,6 +23,7 @@ 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) @@ -191,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/IgnoreItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreItem.kt index 0f9adb8c0..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 @@ -54,7 +54,7 @@ fun MessageIgnoreEntity.toItem() = MessageIgnoreItem( isRegex = isRegex, isCaseSensitive = isCaseSensitive, isBlockMessage = isBlockMessage, - replacement = replacement ?: "", + replacement = replacement.orEmpty(), ) fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( 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 fd4cd3dd2..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 @@ -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 @@ -121,6 +122,7 @@ private fun IgnoresScreen( onPageChange: (Int) -> Unit, onNavBack: () -> Unit, ) { + val dispatchersProvider = koinInject() val resources = LocalResources.current val focusManager = LocalFocusManager.current val snackbarHost = remember { SnackbarHostState() } @@ -140,7 +142,7 @@ private fun IgnoresScreen( LaunchedEffect(eventsWrapper) { eventsWrapper.events - .flowOn(Dispatchers.Main.immediate) + .flowOn(dispatchersProvider.immediate) .collectLatest { event -> focusManager.clearFocus() when (event) { @@ -256,7 +258,7 @@ private fun IgnoresScreen( textAlign = TextAlign.Center, ) PreferenceTabRow( - appBarContainerColor = appbarContainerColor, + appBarContainerColor = appbarContainerColor.value, pagerState = pagerState, tabCount = IgnoresTab.entries.size, tabText = { 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 b24e7c2cd..fd49762de 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,8 +5,8 @@ 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 @@ -18,6 +18,7 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class IgnoresViewModel( private val ignoresRepository: IgnoresRepository, + private val dispatchersProvider: DispatchersProvider, ) : ViewModel() { private val _currentTab = MutableStateFlow(IgnoresTab.Messages) private val eventChannel = Channel(Channel.BUFFERED) @@ -149,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/tools/tts/TTSUserIgnoreListViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListViewModel.kt index 1d86710db..ed484503e 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 @@ -27,7 +27,7 @@ class TTSUserIgnoreListViewModel( ) 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) } } 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 index 71c593d06..565c4595f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt @@ -12,7 +12,7 @@ data class DankChatVersion( fun formattedString(): String = "$major.$minor.$patch" companion object { - private val CURRENT = fromString(BuildConfig.VERSION_NAME)!! + private val CURRENT = checkNotNull(fromString(BuildConfig.VERSION_NAME)) { "Invalid VERSION_NAME: ${BuildConfig.VERSION_NAME}" } private val COMPARATOR = Comparator .comparingInt(DankChatVersion::major) 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 index 98cc077fd..54d5cdb74 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -249,7 +249,7 @@ class ChatMessageMapper( } is SystemMessageType.SendFailed -> { - TextResource.Res(R.string.system_message_send_failed, persistentListOf(type.message ?: "")) + TextResource.Res(R.string.system_message_send_failed, persistentListOf(type.message.orEmpty())) } is SystemMessageType.MessageHistoryUnavailable -> { 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 index 3f0a8eb7b..c8c142e74 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -12,6 +12,7 @@ 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 @@ -22,7 +23,6 @@ import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -48,6 +48,7 @@ class ChatViewModel( private val preferenceStore: DankChatPreferenceStore, appearanceSettingsDataStore: AppearanceSettingsDataStore, chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( @@ -141,7 +142,7 @@ class ChatViewModel( .run { result.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) }.toImmutableList() - }.flowOn(Dispatchers.Default) + }.flowOn(dispatchersProvider.default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), persistentListOf()) fun manageAutomodMessage( 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 index dce3efa02..a21d1baa0 100644 --- 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 @@ -12,6 +12,7 @@ import com.flxrs.dankchat.data.UserName 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.di.DispatchersProvider import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore @@ -30,7 +31,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -55,6 +55,7 @@ class MessageHistoryViewModel( private val preferenceStore: DankChatPreferenceStore, appearanceSettingsDataStore: AppearanceSettingsDataStore, chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( @@ -101,7 +102,7 @@ class MessageHistoryViewModel( ) }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) }.toImmutableList() - }.flowOn(Dispatchers.Default) + }.flowOn(dispatchersProvider.default) private val users: StateFlow> = usersRepository @@ -120,7 +121,7 @@ class MessageHistoryViewModel( .flatMap { it.badges } .mapNotNull { it.badgeTag?.substringBefore('/') } .toImmutableSet() - }.flowOn(Dispatchers.Default) + }.flowOn(dispatchersProvider.default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) val filterSuggestions: StateFlow> = 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 index eb4daa918..bf921ed88 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -15,7 +16,6 @@ import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -36,6 +36,7 @@ class MentionViewModel( private val preferenceStore: DankChatPreferenceStore, appearanceSettingsDataStore: AppearanceSettingsDataStore, chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( @@ -99,7 +100,7 @@ class MentionViewModel( ) }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) }.toImmutableList() - }.flowOn(Dispatchers.Default) + }.flowOn(dispatchersProvider.default) val whispersUiStates: Flow> = combine( @@ -120,5 +121,5 @@ class MentionViewModel( ) }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) }.toImmutableList() - }.flowOn(Dispatchers.Default) + }.flowOn(dispatchersProvider.default) } 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 index 6ebee28e3..c5094730e 100644 --- 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 @@ -240,7 +240,7 @@ private fun PrivMessageText( ) { pushStringAnnotation( tag = "USER", - annotation = "${message.userId?.value ?: ""}|${message.userName.value}|${message.displayName.value}|${message.channel.value}", + annotation = "${message.userId?.value.orEmpty()}|${message.userName.value}|${message.displayName.value}|${message.channel.value}", ) append(message.nameText) pop() 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 index 9681fdaca..eb7e77e47 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -11,7 +12,6 @@ import com.flxrs.dankchat.ui.chat.ChatMessageMapper import com.flxrs.dankchat.utils.extensions.isEven import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -29,6 +29,7 @@ class RepliesViewModel( private val preferenceStore: DankChatPreferenceStore, appearanceSettingsDataStore: AppearanceSettingsDataStore, chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, ) : ViewModel() { val chatDisplaySettings: StateFlow = combine( @@ -79,6 +80,6 @@ class RepliesViewModel( RepliesUiState.Found(uiMessages) } } - }.flowOn(Dispatchers.Default) + }.flowOn(dispatchersProvider.default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(persistentListOf())) } 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 index 68e4c5a8a..d1a15b1af 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -47,6 +47,7 @@ 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.AppearanceSettingsScreen @@ -75,7 +76,6 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -91,6 +91,7 @@ class MainActivity : ComponentActivity() { 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 = @@ -526,8 +527,8 @@ class MainActivity : ComponentActivity() { createMediaFile(this, extension).apply { currentMediaUri = toUri() } } catch (_: IOException) { null - }?.also { - val uri = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", it) + }?.also { file -> + val uri = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", file) captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri) when { captureVideo -> requestVideoCapture.launch(captureIntent) @@ -559,12 +560,12 @@ class MainActivity : ComponentActivity() { ) { lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.UploadLoading) - withContext(Dispatchers.IO) { + withContext(dispatchersProvider.io) { if (imageCapture) { runCatching { file.removeExifAttributes() } } } - val result = withContext(Dispatchers.IO) { dataRepository.uploadMedia(file) } + val result = withContext(dispatchersProvider.io) { dataRepository.uploadMedia(file) } result.fold( onSuccess = { url -> file.delete() 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 index b2e799cf0..aaec88672 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -1023,8 +1023,6 @@ private fun BoxScope.NormalStackedLayout( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current if (!isInPipMode) { Scaffold( 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 index 82fd2517c..445d7c4e1 100644 --- 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 @@ -297,7 +297,7 @@ private fun InlineRenameField( channelWithRename: ChannelWithRename, onRename: (String?) -> Unit, ) { - val initialText = channelWithRename.rename?.value ?: "" + val initialText = channelWithRename.rename?.value.orEmpty() var renameText by remember(channelWithRename.channel) { mutableStateOf( TextFieldValue( 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 index 1041184c1..3b150330c 100644 --- 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 @@ -166,7 +166,7 @@ class ChatInputViewModel( init { viewModelScope.launch { chatChannelProvider.activeChannel.collect { - repeatedSend.update { it.copy(enabled = false) } + repeatedSend.update { data -> data.copy(enabled = false) } setReplying(false) _isAnnouncing.value = false } 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 index 95b9cc403..bc9778176 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -14,7 +15,6 @@ 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.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -32,6 +32,7 @@ class EmoteMenuViewModel( private val dataRepository: DataRepository, chatChannelProvider: ChatChannelProvider, emoteUsageRepository: EmoteUsageRepository, + dispatchersProvider: DispatchersProvider, ) : ViewModel() { private val _selectedTabIndex = MutableStateFlow(0) val selectedTabIndex: StateFlow = _selectedTabIndex.asStateFlow() @@ -53,7 +54,7 @@ class EmoteMenuViewModel( val emoteTabItems: StateFlow> = combine(emotes, recentEmotes, activeChannel) { emotes, recentEmotes, channel -> - withContext(Dispatchers.Default) { + withContext(dispatchersProvider.default) { val sortedEmotes = emotes.sorted val availableRecents = recentEmotes.mapNotNull { usage -> @@ -81,8 +82,8 @@ class EmoteMenuViewModel( listOf( async { EmoteMenuTabItem(EmoteMenuTab.RECENT, availableRecents.toEmoteItems()) }, async { EmoteMenuTabItem(EmoteMenuTab.SUBS, groupedByType[EmoteMenuTab.SUBS].toEmoteItemsWithFront(channel)) }, - async { EmoteMenuTabItem(EmoteMenuTab.CHANNEL, (groupedByType[EmoteMenuTab.CHANNEL] ?: emptyList()).toEmoteItems()) }, - async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, (groupedByType[EmoteMenuTab.GLOBAL] ?: emptyList()).toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.CHANNEL, groupedByType[EmoteMenuTab.CHANNEL].orEmpty().toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, groupedByType[EmoteMenuTab.GLOBAL].orEmpty().toEmoteItems()) }, ).awaitAll().toImmutableList() } }.stateIn( 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 index 18fccb0c7..db4657dc3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt @@ -46,11 +46,11 @@ 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.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject @@ -58,6 +58,7 @@ 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?) { @@ -91,7 +92,7 @@ class ShareUploadActivity : ComponentActivity() { lifecycleScope.launch { uploadState = ShareUploadState.Loading val file = - withContext(Dispatchers.IO) { + withContext(dispatchersProvider.io) { try { val copy = createMediaFile(this@ShareUploadActivity, extension) contentResolver.openInputStream(uri)?.use { input -> @@ -120,7 +121,7 @@ class ShareUploadActivity : ComponentActivity() { } private suspend fun performUpload(file: File) { - val result = withContext(Dispatchers.IO) { dataRepository.uploadMedia(file) } + val result = withContext(dispatchersProvider.io) { dataRepository.uploadMedia(file) } result.fold( onSuccess = { url -> uploadState = ShareUploadState.Success(url) }, onFailure = { error -> 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 3c6263857..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 @@ -29,6 +29,7 @@ inline fun Collection.replaceIf( predicate: (T) -> Boolean, ): List = map { if (predicate(it)) replacement else it } +@Suppress("DoubleMutabilityForCollection") inline fun List.chunkedBy( maxSize: Int, selector: (T) -> Int, From 0f885f3de30fe12ef2ee736114b221948808261e Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 11:45:14 +0200 Subject: [PATCH 290/349] refactor(mod): Convert Action enum to sealed interface, fix room state reactivity and timeout stacking --- .../data/twitch/message/ModerationMessage.kt | 344 ++++++++++++------ .../dankchat/ui/chat/ChatMessageMapper.kt | 7 +- .../ui/main/dialog/MainScreenDialogs.kt | 3 +- .../ui/main/dialog/ModActionsViewModel.kt | 8 +- .../utils/extensions/ModerationOperations.kt | 15 +- 5 files changed, 259 insertions(+), 118 deletions(-) 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 6ccaf022c..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 @@ -30,45 +30,90 @@ data class ModerationMessage( val targetUserDisplay: DisplayName? = null, val sourceBroadcasterDisplay: DisplayName? = null, val targetMsgId: String? = null, - val durationInt: Int? = null, - val duration: TextResource? = 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, - AddBlockedTerm, - AddPermittedTerm, - RemoveBlockedTerm, - RemovePermittedTerm, + 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 hasReason get() = !reason.isNullOrBlank() @@ -98,8 +143,8 @@ data class ModerationMessage( val message = when (action) { - Action.Timeout -> { - val dur = duration ?: TextResource.Plain("") + is Action.Timeout -> { + val dur = action.duration when (targetUser) { currentUser -> { when (creatorUserDisplay) { @@ -240,8 +285,8 @@ data class ModerationMessage( TextResource.Res(R.string.mod_emote_only_off, persistentListOf(creator)) } - Action.Followers -> { - when (val mins = durationInt?.takeIf { it > 0 }) { + 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))) } @@ -259,8 +304,8 @@ data class ModerationMessage( TextResource.Res(R.string.mod_unique_chat_off, persistentListOf(creator)) } - Action.Slow -> { - when (val secs = durationInt) { + 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))) } @@ -278,8 +323,8 @@ data class ModerationMessage( TextResource.Res(R.string.mod_subscribers_off, persistentListOf(creator)) } - Action.SharedTimeout -> { - val dur = duration ?: TextResource.Plain("") + 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)) @@ -331,8 +376,8 @@ data class ModerationMessage( } } - 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 { @@ -371,14 +416,13 @@ data class ModerationMessage( val channel = params[0].substring(1) val target = params.getOrNull(1) val durationSeconds = tags["ban-duration"]?.toIntOrNull() - val duration = durationSeconds?.let(::formatSecondsDuration) val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() val id = tags["id"] ?: "clearchat-$ts-$channel-${target ?: "all"}" val action = when { target == null -> Action.Clear durationSeconds == null -> Action.Ban - else -> Action.Timeout + else -> Action.Timeout(duration = formatSecondsDuration(durationSeconds)) } return ModerationMessage( @@ -388,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, ) } @@ -422,25 +464,23 @@ data class ModerationMessage( 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, ) } @@ -453,45 +493,32 @@ data class ModerationMessage( ): ModerationMessage { val timeZone = TimeZone.currentSystemDefault() val timestampMillis = timestamp.toLocalDateTime(timeZone).toInstant(timeZone).toEpochMilliseconds() - val duration = parseDuration(timestamp, data) - val formattedDuration = duration?.let(::formatSecondsDuration) 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, - ): TextResource? = when (data.moderationAction) { - ModerationActionType.Timeout -> seconds?.let(::formatSecondsDuration) - else -> null - } - - private fun parseDuration( + 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 } @@ -566,8 +593,8 @@ data class ModerationMessage( 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 @@ -577,40 +604,143 @@ data class ModerationMessage( 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 - ChannelModerateAction.AddBlockedTerm -> Action.AddBlockedTerm - ChannelModerateAction.AddPermittedTerm -> Action.AddPermittedTerm - ChannelModerateAction.RemoveBlockedTerm -> Action.RemoveBlockedTerm - ChannelModerateAction.RemovePermittedTerm -> Action.RemovePermittedTerm - 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/ui/chat/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt index 54d5cdb74..3aac9affc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -15,6 +15,7 @@ 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 @@ -391,7 +392,11 @@ class ChatMessageMapper( val arguments = buildList { - duration?.let(::add) + 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() 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 index 836243653..0ec6d52c6 100644 --- 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 @@ -348,8 +348,9 @@ private fun ModActionsDialogContainer( parameters = { parametersOf(channel) }, ) val shieldModeActive by viewModel.shieldModeActive.collectAsStateWithLifecycle() + val roomState by viewModel.roomState.collectAsStateWithLifecycle() ModActionsDialog( - roomState = viewModel.roomState, + roomState = roomState, isBroadcaster = viewModel.isBroadcaster, isStreamActive = isStreamActive, shieldModeActive = shieldModeActive, 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 index 58b8c7478..6642c24f2 100644 --- 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 @@ -7,7 +7,9 @@ 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.android.annotation.KoinViewModel import org.koin.core.annotation.InjectedParam @@ -20,8 +22,10 @@ class ModActionsViewModel( authDataStore: AuthDataStore, ) : ViewModel() { val shieldModeActive: StateFlow = shieldModeRepository.getState(channel) - val roomState: RoomState? = channelRepository.getRoomState(channel) - val isBroadcaster: Boolean = authDataStore.userIdString == roomState?.channelId + 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 { 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 index cd82ba445..7b4c31fb5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -39,9 +39,9 @@ fun List.replaceOrAddModerationMessage( } } - ModerationMessage.Action.Timeout, + is ModerationMessage.Action.Timeout, ModerationMessage.Action.Ban, - ModerationMessage.Action.SharedTimeout, + is ModerationMessage.Action.SharedTimeout, ModerationMessage.Action.SharedBan, -> { item.message as? PrivMessage ?: continue @@ -74,7 +74,7 @@ fun List.replaceWithTimeout( 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) { + 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 } @@ -110,7 +110,7 @@ private fun MutableList.deduplicateOrStack(moderationMessage: Moderati for (idx in lastIndex downTo end) { val item = this[idx] val existing = item.message as? ModerationMessage ?: continue - if (existing.targetUser != moderationMessage.targetUser || existing.action != moderationMessage.action) { + if (existing.targetUser != moderationMessage.targetUser || !existing.action.isSameType(moderationMessage.action)) { continue } @@ -126,13 +126,14 @@ private fun MutableList.deduplicateOrStack(moderationMessage: Moderati Unit } - // EventSub arriving after IRC → replace IRC with EventSub (has moderator info) + // EventSub arriving after IRC → replace IRC with EventSub (has moderator info), preserve stack count moderationMessage.fromEventSource && !existing.fromEventSource -> { - this[idx] = item.copy(tag = item.tag + 1, message = moderationMessage) + 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 == ModerationMessage.Action.Timeout || moderationMessage.action == ModerationMessage.Action.SharedTimeout -> { + 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) } From 17d8f15a6ee64aeac033f0ed8d78b1dab65aa97f Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 11:53:51 +0200 Subject: [PATCH 291/349] chore: Bump version to 4.0.11 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1a39d6f8..1406ef8b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40010 - versionName = "4.0.10" + versionCode = 40011 + versionName = "4.0.11" } androidResources { generateLocaleConfig = true } From 650f6cd1dd4e2f81b897b8d7e51946edfbc36a91 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 16:30:10 +0200 Subject: [PATCH 292/349] fix(replies): Wire up missing onEmoteClick callback in reply sheet --- .../com/flxrs/dankchat/ui/chat/replies/RepliesComposable.kt | 3 +++ .../com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt | 1 + .../kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt | 3 +++ 3 files changed, 7 insertions(+) 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 index c850447e1..b8a58d2e3 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -27,6 +28,7 @@ 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, @@ -46,6 +48,7 @@ fun RepliesComposable( ChatScreenCallbacks( onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, ), animateGifs = displaySettings.animateGifs, modifier = modifier, 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 index dc0735791..d959bf08d 100644 --- 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 @@ -87,6 +87,7 @@ fun FullScreenSheetOverlay( onDismiss = onDismissReplies, onUserClick = mentionableClickHandler(onUserClick, onUserMention, userLongClickBehavior), onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = true), + onEmoteClick = onEmoteClick, bottomContentPadding = bottomContentPadding, ) } 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 index 37786d54d..a912d389e 100644 --- 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 @@ -41,6 +41,7 @@ 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 @@ -55,6 +56,7 @@ fun RepliesSheet( 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 = @@ -114,6 +116,7 @@ fun RepliesSheet( repliesViewModel = viewModel, onUserClick = onUserClick, onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, onMissing = onDismiss, containerColor = sheetBackgroundColor, contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), From e0f63e724d06fc610dba293dd65fe838fcfa5c7a Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 16:41:25 +0200 Subject: [PATCH 293/349] fix(input): Disable swipe-down gesture when keyboard or emote menu is open, close emote menu on input hide --- .../kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index aaec88672..23c79c179 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -430,7 +430,10 @@ fun MainScreen( } }, onToggleFullscreen = mainScreenViewModel::toggleFullscreen, - onToggleInput = mainScreenViewModel::toggleInput, + onToggleInput = { + mainScreenViewModel.toggleInput() + chatInputViewModel.setEmoteMenuOpen(false) + }, onToggleStream = { when { currentStream != null -> streamViewModel.closeStream() @@ -681,6 +684,7 @@ fun MainScreen( InputAction.HideInput -> { mainScreenViewModel.toggleInput() + chatInputViewModel.setEmoteMenuOpen(false) } InputAction.Debug -> { @@ -946,7 +950,7 @@ private fun BoxScope.WideSplitLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = showInput && !isSheetOpen && !isInputMultiline, + enabled = showInput && !isSheetOpen && !isInputMultiline && !isKeyboardVisible && !isEmoteMenuOpen, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), @@ -1133,7 +1137,7 @@ private fun BoxScope.NormalStackedLayout( .align(Alignment.BottomCenter) .padding(bottom = scaffoldBottomPadding) .swipeDownToHide( - enabled = showInput && !isSheetOpen && !isInputMultiline, + enabled = showInput && !isSheetOpen && !isInputMultiline && !isKeyboardVisible && !isEmoteMenuOpen, thresholdPx = swipeDownThresholdPx, onHide = onHideInput, ), From eb9c2e5f3b2a4aabde01b92afd3fcadaace014ea Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 16:46:47 +0200 Subject: [PATCH 294/349] chore: Bump version to 4.0.12 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1406ef8b9..3b7f37af0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40011 - versionName = "4.0.11" + versionCode = 40012 + versionName = "4.0.12" } androidResources { generateLocaleConfig = true } From fcd163d18efba20f055380e5d820e30fc152357d Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 19:06:34 +0200 Subject: [PATCH 295/349] fix(ui): Only show menu scrollbar when a full item is offscreen --- .../com/flxrs/dankchat/ui/chat/ChatScreen.kt | 14 ++++++++++- .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 6 +++-- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 23 ++++++++++++++----- 3 files changed, 34 insertions(+), 9 deletions(-) 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 index f4aa9ee00..8ec997e9b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -65,6 +65,7 @@ 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 @@ -75,6 +76,7 @@ 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 @@ -487,6 +489,7 @@ private fun FabActionsMenu( } val menuMaxHeight = windowHeight * 0.35f val scrollState = rememberScrollState() + var itemHeightPx by remember { mutableIntStateOf(0) } Surface( shape = RoundedCornerShape(12.dp), @@ -501,6 +504,7 @@ private fun FabActionsMenu( .width(IntrinsicSize.Max) .verticalScroll(scrollState), ) { + var measured = false for (action in InputAction.entries) { val item = getFabMenuItem( @@ -519,6 +523,13 @@ private fun FabActionsMenu( 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 = { @@ -526,6 +537,7 @@ private fun FabActionsMenu( onDismiss() }, enabled = actionEnabled, + modifier = measureModifier, leadingIcon = { Icon( imageVector = item.icon, @@ -558,7 +570,7 @@ private fun FabActionsMenu( ) } } - if (scrollState.maxValue > 0) { + if (scrollState.maxValue > itemHeightPx) { VerticalScrollbar( modifier = Modifier 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 index cd9eddedb..7b80aac37 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -470,6 +470,7 @@ fun FloatingToolbar( val maxMenuHeight = screenHeight * 0.3f val quickSwitchScrollState = rememberScrollState() val quickSwitchScrollAreaState = rememberScrollAreaState(quickSwitchScrollState) + var itemHeightPx by remember { mutableIntStateOf(0) } ScrollArea( state = quickSwitchScrollAreaState, modifier = @@ -494,7 +495,8 @@ fun FloatingToolbar( .clickable { onAction(ToolbarAction.SelectTab(index)) showQuickSwitch = false - }.padding(horizontal = 16.dp, vertical = 10.dp), + }.padding(horizontal = 16.dp, vertical = 10.dp) + .then(if (index == 0) Modifier.onSizeChanged { itemHeightPx = it.height } else Modifier), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { @@ -517,7 +519,7 @@ fun FloatingToolbar( } } } - if (quickSwitchScrollState.maxValue > 0) { + if (quickSwitchScrollState.maxValue > itemHeightPx) { VerticalScrollbar( modifier = Modifier 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 index b9b2918c9..9fd008597 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -52,6 +52,7 @@ 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 @@ -59,6 +60,7 @@ 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 @@ -122,6 +124,8 @@ fun InlineOverflowMenu( 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) @@ -164,12 +168,14 @@ fun InlineOverflowMenu( onDismiss = onDismiss, onNavigateToUpload = { currentMenu = AppBarMenu.Upload }, onNavigateToChannel = { currentMenu = AppBarMenu.Channel }, + firstItemModifier = measureModifier, ) AppBarMenu.Upload -> UploadMenuContent( onAction = onAction, onDismiss = onDismiss, onBack = { currentMenu = AppBarMenu.Main }, + firstItemModifier = measureModifier, ) AppBarMenu.Channel -> ChannelMenuContent( @@ -177,11 +183,12 @@ fun InlineOverflowMenu( onAction = onAction, onDismiss = onDismiss, onBack = { currentMenu = AppBarMenu.Main }, + firstItemModifier = measureModifier, ) } } } - if (scrollState.maxValue > 0) { + if (scrollState.maxValue > itemHeightPx) { VerticalScrollbar( modifier = Modifier @@ -207,11 +214,12 @@ private fun InlineMenuItem( text: String, icon: ImageVector, hasSubMenu: Boolean = false, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { Row( modifier = - Modifier + modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), @@ -277,14 +285,15 @@ private fun ColumnScope.MainMenuContent( onDismiss: () -> Unit, onNavigateToUpload: () -> Unit, onNavigateToChannel: () -> Unit, + firstItemModifier: Modifier = Modifier, ) { if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login) { + InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login, modifier = firstItemModifier) { onAction(ToolbarAction.Login) onDismiss() } } else { - InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh) { + InlineMenuItem(text = stringResource(R.string.relogin), icon = Icons.Default.Refresh, modifier = firstItemModifier) { onAction(ToolbarAction.Relogin) onDismiss() } @@ -331,9 +340,10 @@ private fun ColumnScope.UploadMenuContent( onAction: (ToolbarAction) -> Unit, onDismiss: () -> Unit, onBack: () -> Unit, + firstItemModifier: Modifier = Modifier, ) { InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = onBack) - InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt) { + InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt, modifier = firstItemModifier) { onAction(ToolbarAction.CaptureImage) onDismiss() } @@ -353,9 +363,10 @@ private fun ColumnScope.ChannelMenuContent( onAction: (ToolbarAction) -> Unit, onDismiss: () -> Unit, onBack: () -> Unit, + firstItemModifier: Modifier = Modifier, ) { InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = onBack) - InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser) { + InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser, modifier = firstItemModifier) { onAction(ToolbarAction.OpenChannel) onDismiss() } From 33db7524b561031ebb6733e54d92085e6be70697 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 20:47:26 +0200 Subject: [PATCH 296/349] feat(emote): Adaptive emote info layout for wide emotes, smooth pager height interpolation --- .../ui/main/dialog/EmoteInfoDialog.kt | 207 ++++++++++++++---- 1 file changed, 160 insertions(+), 47 deletions(-) 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 index 2090ab2f5..db9eba8da 100644 --- 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 @@ -6,10 +6,14 @@ 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 @@ -26,19 +30,30 @@ 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 @@ -76,9 +91,20 @@ fun EmoteInfoDialog( } } + val density = LocalDensity.current + val pageHeights = remember { IntArray(items.size) } + HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxWidth(), + beyondViewportPageCount = items.size, + overscrollEffect = null, + modifier = + Modifier + .fillMaxWidth() + .clipToBounds() + .then( + pagerState.interpolatedHeightModifier(pageHeights, density), + ), ) { page -> val item = items[page] EmoteInfoContent( @@ -96,6 +122,10 @@ fun EmoteInfoDialog( onOpenLink(item.providerUrl) onDismiss() }, + modifier = + Modifier + .wrapContentHeight(unbounded = true) + .onSizeChanged { pageHeights[page] = it.height }, ) } Spacer(modifier = Modifier.height(16.dp)) @@ -103,6 +133,27 @@ fun EmoteInfoDialog( } } +private fun PagerState.interpolatedHeightModifier( + pageHeights: IntArray, + density: Density, +): Modifier { + val currentHeight = pageHeights.getOrNull(currentPage) ?: 0 + if (currentHeight == 0) return Modifier + + val fraction = currentPageOffsetFraction + val targetPage = when { + fraction > 0 -> (currentPage + 1).coerceAtMost(pageCount - 1) + fraction < 0 -> (currentPage - 1).coerceAtLeast(0) + else -> currentPage + } + val targetHeight = pageHeights.getOrNull(targetPage) ?: currentHeight + val interpolatedPx = lerp(currentHeight, targetHeight, fraction.absoluteValue) + val heightDp = with(density) { interpolatedPx.toDp() } + return Modifier.height(heightDp) +} + +private const val WIDE_EMOTE_THRESHOLD = 1.5f + @Composable private fun EmoteInfoContent( item: EmoteInfoItem, @@ -110,56 +161,19 @@ private fun EmoteInfoContent( 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(), + modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - 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), - ) - Spacer(modifier = Modifier.width(16.dp)) - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) { - 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, - ) - } + if (isWide) { + WideEmoteHeader(item = item, onAspectRatio = { aspectRatio = it }) + } else { + SquareEmoteHeader(item = item, onAspectRatio = { aspectRatio = it }) } if (showUseEmote) { @@ -184,3 +198,102 @@ private fun EmoteInfoContent( ) } } + +@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, + ) + } +} From 7f398288aea261bd285429b760fab5396e8a51e4 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 20:51:36 +0200 Subject: [PATCH 297/349] fix(detekt): Fix composable parameter ordering and naming in MainAppBar --- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 202 ++++++++++++------ 1 file changed, 136 insertions(+), 66 deletions(-) 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 index 9fd008597..9980f948d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -168,14 +168,14 @@ fun InlineOverflowMenu( onDismiss = onDismiss, onNavigateToUpload = { currentMenu = AppBarMenu.Upload }, onNavigateToChannel = { currentMenu = AppBarMenu.Channel }, - firstItemModifier = measureModifier, + modifier = measureModifier, ) AppBarMenu.Upload -> UploadMenuContent( onAction = onAction, onDismiss = onDismiss, onBack = { currentMenu = AppBarMenu.Main }, - firstItemModifier = measureModifier, + modifier = measureModifier, ) AppBarMenu.Channel -> ChannelMenuContent( @@ -183,7 +183,7 @@ fun InlineOverflowMenu( onAction = onAction, onDismiss = onDismiss, onBack = { currentMenu = AppBarMenu.Main }, - firstItemModifier = measureModifier, + modifier = measureModifier, ) } } @@ -213,9 +213,9 @@ fun InlineOverflowMenu( private fun InlineMenuItem( text: String, icon: ImageVector, - hasSubMenu: Boolean = false, - modifier: Modifier = Modifier, onClick: () -> Unit, + modifier: Modifier = Modifier, + hasSubMenu: Boolean = false, ) { Row( modifier = @@ -285,54 +285,98 @@ private fun ColumnScope.MainMenuContent( onDismiss: () -> Unit, onNavigateToUpload: () -> Unit, onNavigateToChannel: () -> Unit, - firstItemModifier: Modifier = Modifier, + modifier: Modifier = Modifier, ) { if (!isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.login), icon = Icons.AutoMirrored.Filled.Login, modifier = firstItemModifier) { - onAction(ToolbarAction.Login) - onDismiss() - } + 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, modifier = firstItemModifier) { - onAction(ToolbarAction.Relogin) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.logout), icon = Icons.AutoMirrored.Filled.Logout) { - onAction(ToolbarAction.Logout) - onDismiss() - } + 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) { - onAction(ToolbarAction.ManageChannels) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.remove_channel), icon = Icons.Default.RemoveCircleOutline) { - onAction(ToolbarAction.RemoveChannel) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.reload_emotes), icon = Icons.Default.EmojiEmotions) { - onAction(ToolbarAction.ReloadEmotes) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.reconnect), icon = Icons.Default.Autorenew) { - onAction(ToolbarAction.Reconnect) - onDismiss() - } + 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, hasSubMenu = true, onClick = onNavigateToUpload) - InlineMenuItem(text = stringResource(R.string.channel), icon = Icons.Default.Info, hasSubMenu = true, onClick = onNavigateToChannel) + 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) { - onAction(ToolbarAction.OpenSettings) - onDismiss() - } + InlineMenuItem( + text = stringResource(R.string.settings), + icon = Icons.Default.Settings, + onClick = { + onAction(ToolbarAction.OpenSettings) + onDismiss() + }, + ) } @Composable @@ -340,21 +384,34 @@ private fun ColumnScope.UploadMenuContent( onAction: (ToolbarAction) -> Unit, onDismiss: () -> Unit, onBack: () -> Unit, - firstItemModifier: Modifier = Modifier, + modifier: Modifier = Modifier, ) { InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = onBack) - InlineMenuItem(text = stringResource(R.string.take_picture), icon = Icons.Default.CameraAlt, modifier = firstItemModifier) { - onAction(ToolbarAction.CaptureImage) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.record_video), icon = Icons.Default.Videocam) { - onAction(ToolbarAction.CaptureVideo) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.choose_media), icon = Icons.Default.Image) { - onAction(ToolbarAction.ChooseMedia) - onDismiss() - } + InlineMenuItem( + text = stringResource(R.string.take_picture), + icon = Icons.Default.CameraAlt, + onClick = { + onAction(ToolbarAction.CaptureImage) + onDismiss() + }, + modifier = modifier, + ) + InlineMenuItem( + text = stringResource(R.string.record_video), + icon = Icons.Default.Videocam, + onClick = { + onAction(ToolbarAction.CaptureVideo) + onDismiss() + }, + ) + InlineMenuItem( + text = stringResource(R.string.choose_media), + icon = Icons.Default.Image, + onClick = { + onAction(ToolbarAction.ChooseMedia) + onDismiss() + }, + ) } @Composable @@ -363,21 +420,34 @@ private fun ColumnScope.ChannelMenuContent( onAction: (ToolbarAction) -> Unit, onDismiss: () -> Unit, onBack: () -> Unit, - firstItemModifier: Modifier = Modifier, + modifier: Modifier = Modifier, ) { InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = onBack) - InlineMenuItem(text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser, modifier = firstItemModifier) { - onAction(ToolbarAction.OpenChannel) - onDismiss() - } - InlineMenuItem(text = stringResource(R.string.report_channel), icon = Icons.Default.Flag) { - onAction(ToolbarAction.ReportChannel) - onDismiss() - } - if (isLoggedIn) { - InlineMenuItem(text = stringResource(R.string.block_channel), icon = Icons.Default.Block) { - onAction(ToolbarAction.BlockChannel) + InlineMenuItem( + text = stringResource(R.string.open_channel), + icon = Icons.Default.OpenInBrowser, + onClick = { + onAction(ToolbarAction.OpenChannel) onDismiss() - } + }, + modifier = modifier, + ) + InlineMenuItem( + text = stringResource(R.string.report_channel), + icon = Icons.Default.Flag, + onClick = { + onAction(ToolbarAction.ReportChannel) + onDismiss() + }, + ) + if (isLoggedIn) { + InlineMenuItem( + text = stringResource(R.string.block_channel), + icon = Icons.Default.Block, + onClick = { + onAction(ToolbarAction.BlockChannel) + onDismiss() + }, + ) } } From 6222969af30c5ab3ef8128fb36b79cdc05bb8388 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 21:05:51 +0200 Subject: [PATCH 298/349] fix(ci): Run detektDebug and lintDebug instead of detekt and lintVitalRelease --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f00a62dec..f6b604bd6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -48,10 +48,10 @@ jobs: run: bash ./gradlew :app:spotlessCheck - name: Detekt - run: bash ./gradlew :app:detekt + run: bash ./gradlew :app:detektDebug - name: Android Lint - run: bash ./gradlew lintVitalRelease + run: bash ./gradlew :app:lintDebug build: name: Generate apk From 7fcb0ff72dd0c1f2e37e922aedc0a0f432c715a6 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 21:06:17 +0200 Subject: [PATCH 299/349] chore: Bump version to 4.0.13 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b7f37af0..cdd335655 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40012 - versionName = "4.0.12" + versionCode = 40013 + versionName = "4.0.13" } androidResources { generateLocaleConfig = true } From 1aab93b4376207a38f7a300cd714ba5a405fc56c Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 21:18:27 +0200 Subject: [PATCH 300/349] fix: Remove redundant else branch in exhaustive when expression --- .../kotlin/com/flxrs/dankchat/data/api/upload/UploadClient.kt | 1 - 1 file changed, 1 deletion(-) 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 d7b4d5cbe..22d5e8aa7 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 @@ -138,7 +138,6 @@ class UploadClient( is JsonObject -> acc.jsonObject[key] ?: return@runCatching null is JsonArray -> acc.jsonArray[key.toInt()] is JsonPrimitive -> return@runCatching acc.content - else -> return@runCatching null } } when (result) { From fc05014c36fd63d3fcf75377923b43869c8e7246 Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 4 Apr 2026 23:58:00 +0200 Subject: [PATCH 301/349] feat(logging): Add logback-android logging with kotlin-logging and in-app log viewer --- app/build.gradle.kts | 2 + app/config/detekt.yml | 2 +- app/proguard-rules.pro | 5 + app/src/main/assets/logback.xml | 33 ++ .../data/api/eventapi/EventSubClient.kt | 43 +- .../seventv/eventapi/SevenTVEventApiClient.kt | 17 +- .../dankchat/data/api/upload/UploadClient.kt | 9 +- .../data/auth/AuthStateCoordinator.kt | 10 +- .../database/entity/BlacklistedUserEntity.kt | 10 +- .../database/entity/MessageHighlightEntity.kt | 10 +- .../database/entity/MessageIgnoreEntity.kt | 10 +- .../data/database/entity/UserIgnoreEntity.kt | 10 +- .../data/notification/NotificationService.kt | 8 +- .../data/repo/HighlightsRepository.kt | 17 +- .../dankchat/data/repo/IgnoresRepository.kt | 15 +- .../data/repo/chat/ChatEventProcessor.kt | 17 +- .../data/repo/chat/ChatMessageSender.kt | 10 +- .../data/repo/chat/RecentMessagesHandler.kt | 7 +- .../data/repo/command/CommandRepository.kt | 10 +- .../dankchat/data/repo/data/DataRepository.kt | 27 +- .../data/repo/emote/EmoteRepository.kt | 8 +- .../flxrs/dankchat/data/repo/log/LogLine.kt | 66 +++ .../dankchat/data/repo/log/LogRepository.kt | 38 ++ .../data/twitch/chat/ChatConnection.kt | 25 +- .../twitch/command/TwitchCommandRepository.kt | 7 +- .../data/twitch/pubsub/PubSubConnection.kt | 33 +- .../data/twitch/pubsub/PubSubManager.kt | 12 +- .../com/flxrs/dankchat/di/NetworkModule.kt | 45 +- .../developer/DeveloperSettingsScreen.kt | 51 +- .../flxrs/dankchat/ui/chat/ChatViewModel.kt | 10 +- .../messages/common/MessageTextRenderer.kt | 6 +- .../flxrs/dankchat/ui/log/LogViewerSheet.kt | 458 ++++++++++++++++++ .../flxrs/dankchat/ui/log/LogViewerState.kt | 15 + .../dankchat/ui/log/LogViewerViewModel.kt | 95 ++++ .../flxrs/dankchat/ui/login/LoginScreen.kt | 6 +- .../flxrs/dankchat/ui/login/LoginViewModel.kt | 10 +- .../flxrs/dankchat/ui/main/MainActivity.kt | 28 +- .../flxrs/dankchat/ui/main/MainDestination.kt | 5 + .../com/flxrs/dankchat/ui/main/MainScreen.kt | 2 + .../ui/main/dialog/MainScreenDialogs.kt | 2 + .../dankchat/ui/main/sheet/DebugInfoSheet.kt | 23 + .../utils/extensions/CoroutineExtensions.kt | 6 +- .../dankchat/utils/extensions/Extensions.kt | 8 +- .../main/res/values-b+zh+Hant+TW/strings.xml | 8 + app/src/main/res/values-be-rBY/strings.xml | 8 + app/src/main/res/values-ca/strings.xml | 8 + app/src/main/res/values-cs/strings.xml | 8 + app/src/main/res/values-de-rDE/strings.xml | 8 + app/src/main/res/values-en-rAU/strings.xml | 8 + app/src/main/res/values-en-rGB/strings.xml | 8 + app/src/main/res/values-en/strings.xml | 8 + app/src/main/res/values-es-rES/strings.xml | 8 + app/src/main/res/values-fi-rFI/strings.xml | 8 + app/src/main/res/values-fr-rFR/strings.xml | 8 + app/src/main/res/values-hu-rHU/strings.xml | 8 + app/src/main/res/values-it/strings.xml | 8 + app/src/main/res/values-ja-rJP/strings.xml | 8 + app/src/main/res/values-kk-rKZ/strings.xml | 8 + app/src/main/res/values-or-rIN/strings.xml | 8 + app/src/main/res/values-pl-rPL/strings.xml | 8 + app/src/main/res/values-pt-rBR/strings.xml | 8 + app/src/main/res/values-pt-rPT/strings.xml | 8 + app/src/main/res/values-ru-rRU/strings.xml | 8 + app/src/main/res/values-sr/strings.xml | 8 + app/src/main/res/values-tr-rTR/strings.xml | 8 + app/src/main/res/values-uk-rUA/strings.xml | 8 + app/src/main/res/values/strings.xml | 9 + app/src/main/res/xml/file_paths.xml | 3 + gradle/libs.versions.toml | 6 + 69 files changed, 1221 insertions(+), 212 deletions(-) create mode 100644 app/src/main/assets/logback.xml create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogLine.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogRepository.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerState.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cdd335655..f399433b5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -212,6 +212,8 @@ dependencies { 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) diff --git a/app/config/detekt.yml b/app/config/detekt.yml index b8a923210..114233971 100644 --- a/app/config/detekt.yml +++ b/app/config/detekt.yml @@ -42,7 +42,7 @@ style: ForbiddenComment: active: false DestructuringDeclarationWithTooManyEntries: - maxDestructuringEntries: 4 + maxDestructuringEntries: 5 LoopWithTooManyJumpStatements: maxJumpCount: 3 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/assets/logback.xml b/app/src/main/assets/logback.xml new file mode 100644 index 000000000..4f61069ab --- /dev/null +++ b/app/src/main/assets/logback.xml @@ -0,0 +1,33 @@ + + + + + [%thread] %msg + + + %logger{23} + + + + + + ${DATA_DIR}/logs/dankchat.log + + %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/data/api/eventapi/EventSubClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClient.kt index 620be9ad9..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,6 +1,5 @@ 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 @@ -14,6 +13,7 @@ import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelCha 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 @@ -51,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( @@ -83,7 +85,7 @@ class EventSubClient( url: String = DEFAULT_URL, twitchReconnect: Boolean = false, ) { - Log.i(TAG, "[EventSub] starting connection, twitchReconnect=$twitchReconnect") + logger.info { "[EventSub] starting connection, twitchReconnect=$twitchReconnect" } emitSystemMessage(message = "[EventSub] connecting, twitchReconnect=$twitchReconnect") if (!twitchReconnect) { @@ -115,7 +117,7 @@ class EventSubClient( } } - // Log.v(TAG, "[EventSub] Received raw message: $raw") + // logger.trace { "[EventSub] Received raw message: $raw" } val jsonObject = json @@ -125,8 +127,8 @@ class EventSubClient( val message = runCatching { json.decodeFromJsonElement(jsonObject) } .getOrElse { - Log.e(TAG, "[EventSub] failed to parse message: $it") - Log.e(TAG, "[EventSub] raw JSON: $jsonObject") + logger.error { "[EventSub] failed to parse message: $it" } + logger.error { "[EventSub] raw JSON: $jsonObject" } emitSystemMessage(message = "[EventSub] failed to parse message: $it") continue } @@ -135,7 +137,7 @@ class EventSubClient( 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) } @@ -174,13 +176,13 @@ class EventSubClient( } } - 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 @@ -190,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() } @@ -213,7 +215,7 @@ 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() } @@ -228,18 +230,18 @@ class EventSubClient( .postEventSubSubscription(request) .getOrElse { // TODO: handle errors, maybe retry? - Log.e(TAG, "[EventSub] failed to subscribe: $it") + 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) } } @@ -250,12 +252,12 @@ class EventSubClient( .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 } } @@ -282,7 +284,7 @@ class EventSubClient( } private fun handleNotification(message: NotificationMessageDto) { - Log.d(TAG, "[EventSub] received notification message: $message") + logger.debug { "[EventSub] received notification message: $message" } val eventSubMessage = when (val event = message.payload.event) { is ChannelModerateDto -> { @@ -334,13 +336,13 @@ class EventSubClient( } 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()) { 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") when ( val url = @@ -373,7 +375,7 @@ 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 } @@ -427,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/seventv/eventapi/SevenTVEventApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventApiClient.kt index 4de6308ad..57c2d3fe1 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 @@ -21,6 +20,7 @@ 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,6 +49,8 @@ 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) @@ -113,7 +115,7 @@ class SevenTVEventApiClient( LiveUpdatesBackgroundBehavior.ThirtyMinutes -> 30.minutes } - Log.d(TAG, "[7TV Event-Api] Sleeping for $timeout until connection is closed") + logger.debug { "[7TV Event-Api] Sleeping for $timeout until connection is closed" } delay(timeout) close() } @@ -231,7 +233,7 @@ class SevenTVEventApiClient( code: Int, reason: String, ) { - Log.d(TAG, "[7TV Event-Api] connection closed") + logger.debug { "[7TV Event-Api] connection closed" } connected = false heartBeatJob?.cancel() } @@ -241,8 +243,8 @@ class SevenTVEventApiClient( t: Throwable, response: Response?, ) { - Log.e(TAG, "[7TV Event-Api] connection failed: $t") - Log.e(TAG, "[7TV Event-Api] attempting to reconnect #$reconnectAttempts..") + logger.error { "[7TV Event-Api] connection failed: $t" } + logger.error { "[7TV Event-Api] attempting to reconnect #$reconnectAttempts.." } connected = false connecting = false heartBeatJob?.cancel() @@ -257,7 +259,7 @@ class SevenTVEventApiClient( connected = true connecting = false reconnectAttempts = 1 - Log.i(TAG, "[7TV Event-Api] connected") + logger.info { "[7TV Event-Api] connected" } } override fun onMessage( @@ -266,7 +268,7 @@ class SevenTVEventApiClient( ) { val message = runCatching { json.decodeFromString(text) }.getOrElse { - Log.d(TAG, "Failed to parse incoming message: ", it) + logger.debug(it) { "Failed to parse incoming message" } return } @@ -376,6 +378,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/upload/UploadClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/UploadClient.kt index 22d5e8aa7..66a8a9c79 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,12 +1,12 @@ 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.DispatchersProvider import com.flxrs.dankchat.di.UploadOkHttpClient 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 @@ -32,6 +32,8 @@ 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, @@ -97,7 +99,7 @@ class UploadClient( } else -> { - Log.e(TAG, "Upload failed with ${response.code} ${response.message}") + 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)) } @@ -112,11 +114,10 @@ class UploadClient( private fun Response.asJson(): Result = runCatching { Json.parseToJsonElement(body.string()) }.onFailure { - Log.d(TAG, "Error parsing JSON from response: ", it) + logger.debug(it) { "Error parsing JSON from response" } } companion object { - private val TAG = UploadClient::class.java.simpleName private val LINK_PATTERN_REGEX = "\\{(.+?)\\}".toRegex() @Suppress("RegExpRedundantEscape") 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 index 9bef57757..c07e8e208 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.data.auth -import android.util.Log import android.webkit.CookieManager import android.webkit.WebStorage import com.flxrs.dankchat.data.UserName @@ -15,6 +14,7 @@ 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 @@ -39,6 +39,8 @@ sealed interface AuthEvent { data object ValidationFailed : AuthEvent } +private val logger = KotlinLogging.logger("AuthStateCoordinator") + @Single class AuthStateCoordinator( private val authDataStore: AuthDataStore, @@ -111,7 +113,7 @@ class AuthStateCoordinator( } else -> { - Log.e(TAG, "Failed to validate token: ${throwable.message}") + logger.error { "Failed to validate token: ${throwable.message}" } AuthEvent.ValidationFailed } } @@ -156,8 +158,4 @@ class AuthStateCoordinator( authDataStore.clearLogin() } } - - companion object { - private val TAG = AuthStateCoordinator::class.java.simpleName - } } 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 b823f7f7a..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( @@ -20,12 +22,8 @@ data class BlacklistedUserEntity( 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/MessageHighlightEntity.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageHighlightEntity.kt index b8116ea91..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( @@ -35,14 +37,10 @@ data class MessageHighlightEntity( else -> """(? """(?= first && other.last <= last companion object { - private val TAG = IgnoresRepository::class.java.simpleName private val DEFAULT_IGNORES = listOf( MessageIgnoreEntity(id = 0, enabled = false, type = MessageIgnoreEntityType.Subscription, pattern = ""), 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 index d77eaf763..3b7e9d6ef 100644 --- 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 @@ -1,6 +1,5 @@ package com.flxrs.dankchat.data.repo.chat -import android.util.Log import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.AutomodHeld @@ -41,6 +40,7 @@ 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 @@ -53,6 +53,8 @@ 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, @@ -161,12 +163,12 @@ class ChatEventProcessor( rewardMutex.withLock { when { knownRewards.containsKey(id) -> { - Log.d(TAG, "Removing known reward $id") + logger.debug { "Removing known reward $id" } knownRewards.remove(id) } else -> { - Log.d(TAG, "Received pubsub reward message with id $id") + logger.debug { "Received pubsub reward message with id $id" } knownRewards[id] = pubSubMessage } } @@ -200,7 +202,7 @@ class ChatEventProcessor( runCatching { ModerationMessage.parseModerationAction(id, timestamp, channelName, data) }.getOrElse { - Log.d(TAG, "Failed to parse event sub moderation message: $it") + logger.debug { "Failed to parse event sub moderation message: $it" } return } @@ -429,7 +431,7 @@ class ChatEventProcessor( chatMessageRepository.findMessage(id, channel, chatNotificationRepository.whispers) } }.getOrElse { - Log.e(TAG, "Failed to parse message", it) + logger.error(it) { "Failed to parse message" } return }?.let { resolveAutomaticRewardCost(it) } ?.let { attachRewardInfo(it, resolvedReward) } ?: return @@ -494,11 +496,11 @@ class ChatEventProcessor( return rewardMutex.withLock { knownRewards[rewardId] ?.also { - Log.d(TAG, "Removing known reward $rewardId") + logger.debug { "Removing known reward $rewardId" } knownRewards.remove(rewardId) } ?: run { - Log.d(TAG, "Waiting for pubsub reward message with id $rewardId") + logger.debug { "Waiting for pubsub reward message with id $rewardId" } withTimeoutOrNull(PUBSUB_TIMEOUT) { chatConnector.pubSubEvents .filterIsInstance() @@ -636,7 +638,6 @@ class ChatEventProcessor( } companion object { - private val TAG = ChatEventProcessor::class.java.simpleName 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/ChatMessageSender.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageSender.kt index 637b03fb1..b95c3f02b 100644 --- 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 @@ -1,6 +1,5 @@ package com.flxrs.dankchat.data.repo.chat -import android.util.Log import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.HelixApiClient import com.flxrs.dankchat.data.api.helix.HelixApiException @@ -12,8 +11,11 @@ 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, @@ -105,7 +107,7 @@ class ChatMessageSender( } }, onFailure = { throwable -> - Log.e(TAG, "Helix send failed", throwable) + logger.error(throwable) { "Helix send failed" } postError(channel, throwable.toSendErrorType()) }, ) @@ -149,8 +151,4 @@ class ChatMessageSender( SystemMessageType.SendFailed(message) } } - - companion object { - private val TAG = ChatMessageSender::class.java.simpleName - } } 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 index 257eae6d9..8f026ffc4 100644 --- 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 @@ -1,6 +1,5 @@ package com.flxrs.dankchat.data.repo.chat -import android.util.Log import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesApiClient @@ -23,12 +22,15 @@ 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, @@ -124,7 +126,7 @@ class RecentMessagesHandler( } } } - }.let { Log.i(TAG, "Parsing message history for #$channel took $it ms") } + }.let { logger.info { "Parsing message history for #$channel took $it ms" } } val messagesFlow = chatMessageRepository.getMessagesFlow(channel) messagesFlow?.update { current -> @@ -178,7 +180,6 @@ class RecentMessagesHandler( } companion object { - private val TAG = RecentMessagesHandler::class.java.simpleName private const val RECENT_MESSAGES_LIMIT_AFTER_RECONNECT = 100 } } 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 0f8f79f03..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,6 +1,5 @@ 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 @@ -21,6 +20,7 @@ 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 @@ -41,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, @@ -193,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>? { @@ -316,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/data/DataRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataRepository.kt index 9f8907f9e..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 @@ -25,6 +24,7 @@ import com.flxrs.dankchat.di.DispatchersProvider 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 @@ -42,6 +42,8 @@ import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import java.io.File +private val logger = KotlinLogging.logger("DataRepository") + @Single class DataRepository( private val helixApiClient: HelixApiClient, @@ -133,7 +135,7 @@ class DataRepository( } suspend fun loadGlobalBadges(): Result = withContext(dispatchersProvider.io) { - measureTimeAndLog(TAG, "global badges") { + measureTimeAndLog(logger, "global badges") { val result = when { authDataStore.isLoggedIn -> helixApiClient.getGlobalBadges().map { it.toBadgeSets() } @@ -145,7 +147,7 @@ class DataRepository( } suspend fun loadDankChatBadges(): Result = withContext(dispatchersProvider.io) { - measureTimeAndLog(TAG, "DankChat badges") { + measureTimeAndLog(logger, "DankChat badges") { dankChatApiClient .getDankChatBadges() .getOrEmitFailure { DataLoadingStep.DankChatBadges } @@ -169,7 +171,7 @@ class DataRepository( channel: UserName, id: UserId, ): Result = withContext(dispatchersProvider.io) { - measureTimeAndLog(TAG, "channel badges for #$id") { + measureTimeAndLog(logger, "channel badges for #$id") { val result = when { authDataStore.isLoggedIn -> helixApiClient.getChannelBadges(id).map { it.toBadgeSets() } @@ -188,7 +190,7 @@ class DataRepository( return@withContext Result.success(Unit) } - measureTimeAndLog(TAG, "FFZ emotes for #$channel") { + measureTimeAndLog(logger, "FFZ emotes for #$channel") { ffzApiClient .getFFZChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } @@ -206,7 +208,7 @@ class DataRepository( return@withContext Result.success(Unit) } - measureTimeAndLog(TAG, "BTTV emotes for #$channel") { + measureTimeAndLog(logger, "BTTV emotes for #$channel") { bttvApiClient .getBTTVChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } @@ -223,7 +225,7 @@ class DataRepository( return@withContext Result.success(Unit) } - measureTimeAndLog(TAG, "7TV emotes for #$channel") { + measureTimeAndLog(logger, "7TV emotes for #$channel") { sevenTVApiClient .getSevenTVChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelSevenTVEmotes(channel, channelId) } @@ -246,7 +248,7 @@ class DataRepository( return@withContext Result.success(Unit) } - measureTimeAndLog(TAG, "cheermotes for #$channel") { + measureTimeAndLog(logger, "cheermotes for #$channel") { helixApiClient .getCheermotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } @@ -260,7 +262,7 @@ class DataRepository( return@withContext Result.success(Unit) } - measureTimeAndLog(TAG, "global FFZ emotes") { + measureTimeAndLog(logger, "global FFZ emotes") { ffzApiClient .getFFZGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalFFZEmotes } @@ -274,7 +276,7 @@ class DataRepository( return@withContext Result.success(Unit) } - measureTimeAndLog(TAG, "global BTTV emotes") { + measureTimeAndLog(logger, "global BTTV emotes") { bttvApiClient .getBTTVGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalBTTVEmotes } @@ -288,7 +290,7 @@ class DataRepository( return@withContext Result.success(Unit) } - measureTimeAndLog(TAG, "global 7TV emotes") { + measureTimeAndLog(logger, "global 7TV emotes") { sevenTVApiClient .getSevenTVGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalSevenTVEmotes } @@ -299,12 +301,11 @@ class DataRepository( private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = onFailure { throwable -> val loadingStep = step() - Log.e(TAG, "Data request failed [$loadingStep]:", throwable) + 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/emote/EmoteRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepository.kt index 4443b94f7..326ad5456 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 @@ -3,7 +3,6 @@ package com.flxrs.dankchat.data.repo.emote import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable -import android.util.Log import android.util.LruCache import androidx.annotation.VisibleForTesting import androidx.core.graphics.toColorInt @@ -51,6 +50,7 @@ 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 io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -62,6 +62,8 @@ 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 helixApiClient: HelixApiClient, @@ -515,7 +517,7 @@ class EmoteRepository( } } - Log.d(TAG, "Helix getUserEmotes: $totalCount total, ${seenIds.size} unique, ${allEmotes.size} resolved") + logger.debug { "Helix getUserEmotes: $totalCount total, ${seenIds.size} unique, ${allEmotes.size} resolved" } } suspend fun setFFZEmotes( @@ -987,8 +989,6 @@ class EmoteRepository( } companion object { - private val TAG = EmoteRepository::class.java.simpleName - private val ESCAPE_TAG = 0x000E0002.codePointAsString val ESCAPE_TAG_REGEX = "(? 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..6905a4a0a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogRepository.kt @@ -0,0 +1,38 @@ +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 deleteAllLogs() { + if (logDir.exists()) { + logDir.listFiles()?.forEach { it.delete() } + } + } +} 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 b621c4558..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,12 +1,12 @@ package com.flxrs.dankchat.data.twitch.chat -import android.util.Log 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.utils.extensions.timer +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 @@ -38,6 +38,8 @@ 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, @@ -208,14 +210,14 @@ class ChatConnection( 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..") + logger.error { "[$chatConnectionType] authentication failed with expired token, closing connection.." } receiveChannel.send(ChatEvent.LoginFailed) return@webSocket } when (ircMessage.command) { "376" -> { - Log.i(TAG, "[$chatConnectionType] connected to irc") + logger.info { "[$chatConnectionType] connected to irc" } pingJob = setupPingInterval() channelsToJoin.send(channels) } @@ -227,7 +229,7 @@ class ChatConnection( ?.substring(1) ?.toUserName() ?: return@forEach if (channelsAttemptedToJoin.remove(channel)) { - Log.i(TAG, "[$chatConnectionType] Joined #$channel") + logger.info { "[$chatConnectionType] Joined #$channel" } } } @@ -244,7 +246,7 @@ class ChatConnection( } "RECONNECT" -> { - Log.i(TAG, "[$chatConnectionType] server requested reconnect") + logger.info { "[$chatConnectionType] server requested reconnect" } serverRequestedReconnect = true return@webSocket } @@ -269,15 +271,15 @@ class ChatConnection( receiveChannel.send(ChatEvent.Closed) if (!serverRequestedReconnect) { - Log.i(TAG, "[$chatConnectionType] connection closed") + logger.info { "[$chatConnectionType] connection closed" } return@launch } - Log.i(TAG, "[$chatConnectionType] reconnecting after server request") + logger.info { "[$chatConnectionType] reconnecting after server request" } } catch (t: CancellationException) { throw t } catch (t: Throwable) { - Log.e(TAG, "[$chatConnectionType] connection failed: $t") - Log.e(TAG, "[$chatConnectionType] attempting to reconnect #$retryCount..") + logger.error { "[$chatConnectionType] connection failed: $t" } + logger.error { "[$chatConnectionType] attempting to reconnect #$retryCount.." } _connected.value = false session = null channelsAttemptedToJoin.clear() @@ -290,7 +292,7 @@ class ChatConnection( } } - Log.e(TAG, "[$chatConnectionType] connection failed after $RECONNECT_MAX_ATTEMPTS retries") + logger.error { "[$chatConnectionType] connection failed after $RECONNECT_MAX_ATTEMPTS retries" } _connected.value = false session = null } @@ -338,7 +340,7 @@ class ChatConnection( } private fun setupJoinCheckInterval(channelsToCheck: List) = scope.launch { - Log.d(TAG, "[$chatConnectionType] setting up join check for $channelsToCheck") + logger.debug { "[$chatConnectionType] setting up join check for $channelsToCheck" } if (session?.isActive != true || !_connected.value || channelsAttemptedToJoin.isEmpty()) { return@launch } @@ -376,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/TwitchCommandRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommandRepository.kt index 14e3cb03f..42c48bbef 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,5 @@ 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 @@ -24,10 +23,13 @@ import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.message.RoomState 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, @@ -805,7 +807,7 @@ class TwitchCommandRepository( targetUser: DisplayName? = null, formatRange: ((IntRange) -> String)? = null, ): TextResource { - Log.v(TAG, "Command failed: $this") + logger.trace { "Command failed: $this" } if (this !is HelixApiException) { return TextResource.Res(R.string.cmd_error_unknown) } @@ -956,7 +958,6 @@ class TwitchCommandRepository( val ALL_COMMAND_TRIGGERS = ALLOWED_IRC_COMMAND_TRIGGERS + TwitchCommand.ALL_COMMANDS.flatMap { asCommandTriggers(it.trigger) } - private val TAG = TwitchCommandRepository::class.java.simpleName private val VALID_HELIX_COLORS = listOf( "blue", 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 1e252d1df..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,6 +1,5 @@ package com.flxrs.dankchat.data.twitch.pubsub -import android.util.Log import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.ifBlank @@ -12,6 +11,7 @@ import com.flxrs.dankchat.data.twitch.pubsub.dto.moderation.ModeratorAddedData import com.flxrs.dankchat.data.twitch.pubsub.dto.redemption.PointRedemption import com.flxrs.dankchat.utils.extensions.decodeOrNull import com.flxrs.dankchat.utils.extensions.timer +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 @@ -45,6 +45,8 @@ 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, @@ -88,7 +90,7 @@ class PubSubConnection( topics.clear() topics.addAll(possibleTopics) - Log.i(TAG, "[PubSub $tag] connecting with ${possibleTopics.size} topics") + logger.info { "[PubSub $tag] connecting with ${possibleTopics.size} topics" } connectionJob = scope.launch { var retryCount = 1 @@ -99,12 +101,12 @@ class PubSubConnection( session = this retryCount = 1 receiveChannel.trySend(PubSubEvent.Connected) - Log.i(TAG, "[PubSub $tag] connected") + logger.info { "[PubSub $tag] connected" } possibleTopics .toRequestMessages() .forEach { send(Frame.Text(it)) } - Log.d(TAG, "[PubSub $tag] sent LISTEN for ${possibleTopics.size} topics") + logger.debug { "[PubSub $tag] sent LISTEN for ${possibleTopics.size} topics" } var pingJob: Job? = null try { @@ -136,15 +138,15 @@ class PubSubConnection( receiveChannel.trySend(PubSubEvent.Closed) if (!serverRequestedReconnect) { - Log.i(TAG, "[PubSub $tag] connection closed") + logger.info { "[PubSub $tag] connection closed" } return@launch } - Log.i(TAG, "[PubSub $tag] reconnecting after server request") + logger.info { "[PubSub $tag] reconnecting after server request" } } catch (t: CancellationException) { throw t } catch (t: Throwable) { - Log.e(TAG, "[PubSub $tag] connection failed: $t") - Log.e(TAG, "[PubSub $tag] attempting to reconnect #$retryCount..") + logger.error { "[PubSub $tag] connection failed: $t" } + logger.error { "[PubSub $tag] attempting to reconnect #$retryCount.." } session = null receiveChannel.trySend(PubSubEvent.Closed) @@ -155,7 +157,7 @@ class PubSubConnection( } } - Log.e(TAG, "[PubSub $tag] connection failed after $RECONNECT_MAX_ATTEMPTS retries") + logger.error { "[PubSub $tag] connection failed after $RECONNECT_MAX_ATTEMPTS retries" } session = null } @@ -172,7 +174,7 @@ class PubSubConnection( topics.addAll(needsListen) if (needsListen.isNotEmpty()) { - Log.d(TAG, "[PubSub $tag] listening to ${needsListen.size} new topics") + logger.debug { "[PubSub $tag] listening to ${needsListen.size} new topics" } } val currentSession = session needsListen @@ -205,14 +207,14 @@ class PubSubConnection( } fun reconnect() { - Log.i(TAG, "[PubSub $tag] reconnecting") + logger.info { "[PubSub $tag] reconnecting" } close() connect(topics) } fun reconnectIfNecessary() { if (connected || connectionJob?.isActive == true) return - Log.i(TAG, "[PubSub $tag] connection lost, reconnecting") + logger.info { "[PubSub $tag] connection lost, reconnecting" } reconnect() } @@ -221,7 +223,7 @@ class PubSubConnection( topics.removeAll(foundTopics) if (foundTopics.isNotEmpty()) { - Log.d(TAG, "[PubSub $tag] unlistening from ${foundTopics.size} topics") + logger.debug { "[PubSub $tag] unlistening from ${foundTopics.size} topics" } } val currentSession = session foundTopics @@ -260,14 +262,14 @@ class PubSubConnection( } "RECONNECT" -> { - Log.i(TAG, "[PubSub $tag] server requested reconnect") + logger.info { "[PubSub $tag] server requested reconnect" } return true } "RESPONSE" -> { val error = json.optString("error") if (error.isNotBlank()) { - Log.w(TAG, "[PubSub $tag] RESPONSE error: $error") + logger.warn { "[PubSub $tag] RESPONSE error: $error" } } } @@ -393,7 +395,6 @@ class PubSubConnection( private val PING_INTERVAL = 5.minutes private const val PING_PAYLOAD = "{\"type\":\"PING\"}" private const val PUBSUB_URL = "wss://pubsub-edge.twitch.tv" - private val TAG = PubSubConnection::class.java.simpleName private val POINT_REDEMPTION_TOPICS = setOf("reward-redeemed", "automatic-reward-redeemed") } } 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 f88fdd5be..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,6 +1,5 @@ package com.flxrs.dankchat.data.twitch.pubsub -import android.util.Log import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.auth.StartupValidationHolder @@ -11,6 +10,7 @@ import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.di.DispatchersProvider 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 @@ -28,6 +28,8 @@ import kotlinx.serialization.json.Json 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, @@ -64,12 +66,12 @@ class PubSubManager( }.collect { (userId, channels, shouldUsePubSub) -> closeAll() if (userId == null) { - Log.d(TAG, "[PubSub] skipping connection, not logged in") + logger.debug { "[PubSub] skipping connection, not logged in" } return@collect } val resolved = channelRepository.getChannels(channels) val topics = buildTopics(userId, resolved, shouldUsePubSub) - Log.i(TAG, "[PubSub] rebuilding connections for ${resolved.size} channels, ${topics.size} topics (pubsub=$shouldUsePubSub)") + logger.info { "[PubSub] rebuilding connections for ${resolved.size} channels, ${topics.size} topics (pubsub=$shouldUsePubSub)" } listen(topics) } } @@ -168,8 +170,4 @@ class PubSubManager( } } } - - companion object { - private val TAG = PubSubManager::class.java.simpleName - } } 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 2b659f22c..a3fda6968 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 @@ -15,6 +14,7 @@ import com.flxrs.dankchat.data.api.supibot.SupibotApi 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 @@ -76,27 +76,30 @@ 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 = 30_000 + requestTimeoutMillis = 30_000 + socketTimeoutMillis = 30_000 + } } } 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 index 06a4d09d0..a9bb6cf19 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.preferences.developer import android.content.ClipData 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 @@ -66,6 +67,7 @@ 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.repo.log.LogRepository import com.flxrs.dankchat.preferences.components.ExpandablePreferenceItem import com.flxrs.dankchat.preferences.components.NavigationBarSpacer import com.flxrs.dankchat.preferences.components.PreferenceCategory @@ -84,7 +86,10 @@ import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @Composable -fun DeveloperSettingsScreen(onBack: () -> Unit) { +fun DeveloperSettingsScreen( + onBack: () -> Unit, + onOpenLogViewer: (fileName: String) -> Unit = {}, +) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value @@ -120,6 +125,7 @@ fun DeveloperSettingsScreen(onBack: () -> Unit) { snackbarHostState = snackbarHostState, onInteraction = { viewModel.onInteraction(it) }, onBack = onBack, + onOpenLogViewer = onOpenLogViewer, ) } @@ -130,6 +136,7 @@ private fun DeveloperSettingsContent( snackbarHostState: SnackbarHostState, onInteraction: (DeveloperSettingsInteraction) -> Unit, onBack: () -> Unit, + onOpenLogViewer: (fileName: String) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -157,6 +164,48 @@ private fun DeveloperSettingsContent( .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 -> + Text( + text = file.name, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .clickable { + scope.launch { + sheetState.hide() + dismiss() + } + onOpenLogViewer(file.name) + }.padding(horizontal = 16.dp, vertical = 14.dp), + ) + } + } + Spacer(Modifier.height(32.dp)) + } + } + } SwitchPreferenceItem( title = stringResource(R.string.preference_debug_mode_title), summary = stringResource(R.string.preference_debug_mode_summary), 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 index c8c142e74..b71b97bec 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.ui.chat -import android.util.Log import android.util.LruCache import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel @@ -20,6 +19,7 @@ import com.flxrs.dankchat.preferences.chat.ChatSettings import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils import com.flxrs.dankchat.utils.extensions.isEven +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -38,6 +38,8 @@ 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, @@ -157,7 +159,7 @@ class ChatViewModel( helixApiClient .manageAutomodMessage(userId, heldMessageId, action) .onFailure { error -> - Log.e(TAG, "Failed to $action automod message $heldMessageId", error) + logger.error(error) { "Failed to $action automod message $heldMessageId" } val statusCode = (error as? HelixApiException)?.status?.value chatMessageRepository.addSystemMessage( channel, @@ -166,10 +168,6 @@ class ChatViewModel( } } } - - companion object { - private val TAG = ChatViewModel::class.java.simpleName - } } @Immutable 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 index 97d2300e1..76943d78e 100644 --- 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 @@ -1,7 +1,6 @@ package com.flxrs.dankchat.ui.chat.messages.common import android.content.Context -import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.fillMaxWidth @@ -27,10 +26,13 @@ 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, @@ -127,7 +129,7 @@ fun launchCustomTab( .build() .launchUrl(context, url.toUri()) } catch (e: Exception) { - Log.e("MessageUrl", "Error launching URL", e) + logger.error(e) { "Error launching URL" } } } 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..71ee57def --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerSheet.kt @@ -0,0 +1,458 @@ +package com.flxrs.dankchat.ui.log + +import androidx.activity.compose.PredictiveBackHandler +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.foundation.background +import androidx.compose.foundation.horizontalScroll +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.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.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.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 org.koin.compose.viewmodel.koinViewModel + +@Composable +fun LogViewerSheet(onDismiss: () -> Unit) { + val viewModel: LogViewerViewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + 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 + } + 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 }, + ) { _, line -> + LogLineItem(line = line) + } + } + + // 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, + 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)) + }, + 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) + } + } +} + +@Composable +private fun LogViewerToolbar( + visible: Boolean, + statusBarHeight: Dp, + sheetBackgroundColor: Color, + levelFilter: LogLevel?, + onBack: () -> Unit, + onLevelFilter: (LogLevel) -> Unit, + onShare: () -> 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)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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 + 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) { + Text( + text = formatLogLine(line), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + modifier = Modifier.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) { + 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.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..c370b50c9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerViewModel.kt @@ -0,0 +1,95 @@ +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.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.android.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("") + + init { + val route = savedStateHandle.toRoute() + val initialFile = route.fileName.takeIf { it.isNotEmpty() } + _selectedFileName.value = initialFile ?: logRepository + .getLogFiles() + .firstOrNull() + ?.name + .orEmpty() + } + + 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 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 index 5687d4255..5ec3fa7ba 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.ui.login import android.annotation.SuppressLint import android.graphics.Bitmap -import android.util.Log import android.view.ViewGroup import android.webkit.WebResourceError import android.webkit.WebResourceRequest @@ -34,8 +33,11 @@ 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( @@ -135,7 +137,7 @@ fun LoginScreen( request: WebResourceRequest?, error: WebResourceError?, ) { - Log.e("LoginScreen", "Error: ${error?.description}") + logger.error { "Error: ${error?.description}" } isLoading = false } } 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 index 3cd9151ff..6b1158b76 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt @@ -1,16 +1,18 @@ package com.flxrs.dankchat.ui.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.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.android.annotation.KoinViewModel +private val logger = KotlinLogging.logger("LoginViewModel") + @KoinViewModel class LoginViewModel( private val authApiClient: AuthApiClient, @@ -40,7 +42,7 @@ class LoginViewModel( authApiClient.validateUser(token).fold( onSuccess = { saveLoginDetails(token, it) }, onFailure = { - Log.e(TAG, "Failed to validate token: ${it.message}") + logger.error { "Failed to validate token: ${it.message}" } TokenParseEvent(successful = false) }, ) @@ -59,8 +61,4 @@ class LoginViewModel( ) return TokenParseEvent(successful = true) } - - companion object { - private val TAG = LoginViewModel::class.java.simpleName - } } 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 index d1a15b1af..79037b59f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.os.Bundle import android.os.IBinder import android.provider.MediaStore -import android.util.Log import android.webkit.MimeTypeMap import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -65,6 +64,7 @@ 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.log.LogViewerSheet import com.flxrs.dankchat.ui.login.LoginScreen import com.flxrs.dankchat.ui.onboarding.OnboardingDataStore import com.flxrs.dankchat.ui.onboarding.OnboardingScreen @@ -76,6 +76,7 @@ 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 @@ -84,6 +85,8 @@ 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() @@ -153,7 +156,7 @@ class MainActivity : ComponentActivity() { viewModel.serviceEvents .flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED) .onEach { - Log.i(TAG, "Received service event: $it") + logger.info { "Received service event: $it" } when (it) { ServiceEvent.Shutdown -> handleShutDown() } @@ -162,7 +165,7 @@ class MainActivity : ComponentActivity() { viewModel.keepScreenOn .flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED) .onEach { - Log.i(TAG, "Setting FLAG_KEEP_SCREEN_ON to $it") + logger.info { "Setting FLAG_KEEP_SCREEN_ON to $it" } keepScreenOn(it) }.launchIn(lifecycleScope) } @@ -247,6 +250,9 @@ class MainActivity : ComponentActivity() { onChooseMedia = { requestGalleryMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) }, + onOpenLogViewer = { + navController.navigate(LogViewer()) + }, ) } composable( @@ -428,6 +434,17 @@ class MainActivity : ComponentActivity() { ) { DeveloperSettingsScreen( onBack = { navController.popBackStack() }, + onOpenLogViewer = { fileName -> navController.navigate(LogViewer(fileName)) }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + LogViewerSheet( + onDismiss = { navController.popBackStack() }, ) } composable( @@ -481,7 +498,7 @@ class MainActivity : ComponentActivity() { ContextCompat.startForegroundService(this, it) bindService(it, twitchServiceConnection, BIND_AUTO_CREATE) } catch (t: Throwable) { - Log.e(TAG, Log.getStackTraceString(t)) + logger.error(t) { "Failed to start foreground service" } } } } @@ -494,7 +511,7 @@ class MainActivity : ComponentActivity() { try { unbindService(twitchServiceConnection) } catch (t: Throwable) { - Log.e(TAG, Log.getStackTraceString(t)) + logger.error(t) { "Failed to unbind service" } } } } @@ -600,7 +617,6 @@ class MainActivity : ComponentActivity() { } 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/ui/main/MainDestination.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt index 8c45f119a..c80020a94 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt @@ -58,3 +58,8 @@ object Login @Serializable object Onboarding + +@Serializable +data class LogViewer( + val fileName: String = "", +) 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 index 23c79c179..f14b664cc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -122,6 +122,7 @@ fun MainScreen( onCaptureImage: () -> Unit, onCaptureVideo: () -> Unit, onChooseMedia: () -> Unit, + onOpenLogViewer: () -> Unit = {}, modifier: Modifier = Modifier, ) { val density = LocalDensity.current @@ -282,6 +283,7 @@ fun MainScreen( onLogin = onLogin, onReportChannel = onReportChannel, onOpenUrl = onOpenUrl, + onOpenLogViewer = onOpenLogViewer, onJumpToMessage = { messageId, channel -> val target = channelPagerViewModel.resolveJumpTarget(channel, messageId) if (target != null) { 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 index 0ec6d52c6..4c8f2d16b 100644 --- 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 @@ -77,6 +77,7 @@ fun MainScreenDialogs( onReportChannel: () -> Unit, onOpenUrl: (String) -> Unit, onJumpToMessage: (messageId: String, channel: UserName) -> Unit = { _, _ -> }, + onOpenLogViewer: () -> Unit = {}, ) { val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() @@ -253,6 +254,7 @@ fun MainScreenDialogs( viewModel = debugInfoViewModel, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), onDismiss = sheetNavigationViewModel::closeInputSheet, + onOpenLogViewer = onOpenLogViewer, ) } } 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 index cb1580967..ad5a45826 100644 --- 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 @@ -26,10 +26,13 @@ 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 @@ -40,6 +43,7 @@ fun DebugInfoSheet( viewModel: DebugInfoViewModel, sheetState: SheetState, onDismiss: () -> Unit, + onOpenLogViewer: () -> Unit, ) { val sections by viewModel.sections.collectAsStateWithLifecycle() @@ -78,6 +82,25 @@ fun DebugInfoSheet( 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), + ) + } + } } } } 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 65b577f92..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,6 +11,8 @@ 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() } @@ -25,7 +27,7 @@ fun CoroutineScope.timer( try { action(scope) } catch (ex: Exception) { - Log.e("TimerScope", Log.getStackTraceString(ex)) + logger.error(ex) { "TimerScope error" } } if (scope.isCancelled) { 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 c885d09f2..3b51b8b8f 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,11 +3,11 @@ 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.core.content.ContextCompat import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.GenericEmote import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem +import io.github.oshai.kotlinlogging.KLogger import kotlinx.serialization.json.Json fun List?.toEmoteItems(): List = this @@ -47,14 +47,14 @@ inline fun measureTimeValue(block: () -> V): Pair { } inline fun measureTimeAndLog( - tag: String, + 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 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 8d4feb69e..05da9619d 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 @@ -847,4 +847,12 @@ 您的訊息太長了。 您被限速了。請稍後再試。 目標使用者 + 日誌檢視器 + 檢視應用程式日誌 + 日誌 + 分享日誌 + 檢視日誌 + 沒有可用的日誌檔案 + 搜尋日誌 + 捲動至底部 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 0505f7df4..77f8090c6 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -865,4 +865,12 @@ Ваша паведамленне занадта доўгае. Вашы запыты абмежаваныя. Паспрабуйце зноў праз хвіліну. Мэтавы карыстальнік + Прагляд логаў + Прагляд логаў праграмы + Логі + Падзяліцца логамі + Праглядзець логі + Няма даступных файлаў логаў + Пошук у логах + Пракруціць уніз diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 0c3c7bf9a..23a2b4383 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -890,4 +890,12 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 + Desplaça cap avall diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7bd85c1c8..37fd0c6c8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -865,4 +865,12 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 + Posunout dolů diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 3b261be63..ec593eadf 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -864,4 +864,12 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 + Nach unten scrollen diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 714013ab7..12d78e4af 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -843,4 +843,12 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + Scroll to bottom diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 7ba48dd79..86f8d823d 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -843,4 +843,12 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + Scroll to bottom diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index e0e1d7dd5..a93b77e83 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -858,4 +858,12 @@ 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 + Scroll to bottom diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index faec92111..111e58ce3 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -874,4 +874,12 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 + Desplazar hacia abajo diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 2dd79001b..6f4eff887 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -865,4 +865,12 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 + Vieritä alas diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 929802faa..6cb6fb32d 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -858,4 +858,12 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 + Défiler vers le bas diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index e46c5a3c8..bafb9c524 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -841,4 +841,12 @@ 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 + Görgetés az aljára diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b69df0489..f1b49257b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -841,4 +841,12 @@ 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 + Scorri verso il basso diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index d089bf554..d4677e161 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -821,4 +821,12 @@ メッセージが長すぎます。 レート制限されています。しばらくしてからもう一度お試しください。 対象ユーザー + ログビューア + アプリケーションログを表示 + ログ + ログを共有 + ログを表示 + ログファイルがありません + ログを検索 + 一番下にスクロール diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 5219f6105..0e81bc5a7 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -846,4 +846,12 @@ Хабарламаңыз тым ұзын. Жылдамдығыңыз шектелді. Біраз уақыттан кейін қайталаңыз. Мақсатты пайдаланушы + Журнал көрсеткіш + Қолданба журналдарын көру + Журналдар + Журналдарды бөлісу + Журналдарды көру + Журнал файлдары жоқ + Журналдарды іздеу + Төменге айналдыру diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index aea3d501f..3cda022f2 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -846,4 +846,12 @@ ଆପଣଙ୍କ ମେସେଜ୍ ବହୁତ ଲମ୍ବା ଥିଲା। ଆପଣଙ୍କ ହାର ସୀମିତ ହୋଇଛି। କିଛି ସମୟ ପରେ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। ଲକ୍ଷ୍ୟ ୟୁଜର + ଲଗ୍ ଦର୍ଶକ + ଆପ୍ଲିକେସନ୍ ଲଗ୍ ଦେଖନ୍ତୁ + ଲଗ୍ + ଲଗ୍ ସେୟାର କରନ୍ତୁ + ଲଗ୍ ଦେଖନ୍ତୁ + କୌଣସି ଲଗ୍ ଫାଇଲ୍ ଉପಲବ୍ଧ ନାହିଁ + ଲଗ୍ ଖୋଜନ୍ତୁ + ତଳକୁ ସ୍କ୍ରୋଲ୍ କରନ୍ତୁ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 0c8425164..282345873 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -883,4 +883,12 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje 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 + Przewiń na dół diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b84c6c97e..db3fec598 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -853,4 +853,12 @@ 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 + Rolar para baixo diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 270134ab3..eedede5fb 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -843,4 +843,12 @@ 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 + Deslocar para baixo diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index c49484c10..b4cafd52e 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -870,4 +870,12 @@ Ваше сообщение было слишком длинным. Частота ваших запросов ограничена. Попробуйте через мгновение. Целевой пользователь + Просмотр журналов + Просмотр журналов приложения + Журналы + Поделиться журналами + Просмотреть журналы + Файлы журналов отсутствуют + Поиск по журналам + Прокрутить вниз diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 950ff9f48..ee0ab8d95 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -894,4 +894,12 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Ваша порука је била предуга. Ограничена вам је брзина. Покушајте поново за тренутак. Циљни корисник + Прегледач логова + Прегледајте логове апликације + Логови + Подели логове + Прегледај логове + Нема доступних лог фајлова + Претражи логове + Помери на дно diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 809b1e7e8..aa4ca0b3d 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -862,4 +862,12 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 + En alta kaydır diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index c165cf845..80583dd90 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -867,4 +867,12 @@ Ваше повідомлення було занадто довгим. Частоту ваших запитів обмежено. Спробуйте через мить. Цільовий користувач + Перегляд журналів + Переглянути журнали застосунку + Журнали + Поділитися журналами + Переглянути журнали + Файли журналів відсутні + Пошук у журналах + Прокрутити донизу diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4db5d19c2..137366a2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -514,6 +514,8 @@ Channel data Developer options debug_mode_key + Log viewer + View application logs Debug mode Show debug analytics action in input bar timestamp_format_key @@ -719,6 +721,13 @@ Message history History: %1$s Search messages… + + Logs + Share logs + View logs + No log files available + Search logs + Scroll to bottom Filter by username Messages containing links Messages containing emotes 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd4506799..e4ec7a8a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,9 @@ material = "1.13.0" flexBox = "3.0.0" autoLinkText = "2.0.2" +logbackAndroid = "3.0.0" +kotlinLogging = "7.0.7" + processPhoenix = "3.0.0" colorPicker = "3.1.0" @@ -136,6 +139,9 @@ colorpicker-android = { module = "com.github.martin-stone:hsv-alpha-color-picker 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" } From bffbad96f75bf1d0878b118d941cc5bb828aad42 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 00:09:19 +0200 Subject: [PATCH 302/349] fix(toolbar): Close emote menu and keyboard consistently when opening toolbar menus --- .../com/flxrs/dankchat/ui/main/FloatingToolbar.kt | 11 +++++++++++ .../kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt | 2 ++ 2 files changed, 13 insertions(+) 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 index 7b80aac37..35bb9bd2b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -133,6 +133,8 @@ fun FloatingToolbar( onAddChannelTooltipDismiss: () -> Unit = {}, onSkipTour: () -> Unit = {}, keyboardHeightDp: Dp = 0.dp, + isEmoteMenuOpen: Boolean = false, + onCloseEmoteMenu: () -> Unit = {}, streamToolbarAlpha: Float = 1f, ) { val density = LocalDensity.current @@ -168,6 +170,12 @@ fun FloatingToolbar( showQuickSwitch = false } } + LaunchedEffect(isEmoteMenuOpen) { + if (isEmoteMenuOpen) { + showOverflowMenu = false + showQuickSwitch = false + } + } // Dismiss scrim for menus if (showOverflowMenu || showQuickSwitch) { @@ -408,6 +416,8 @@ fun FloatingToolbar( .clickable { showOverflowMenu = false showQuickSwitch = !showQuickSwitch + keyboardController?.hide() + onCloseEmoteMenu() }.defaultMinSize(minHeight = 48.dp) .padding(start = 4.dp, end = 8.dp) .drawBehind { @@ -643,6 +653,7 @@ fun FloatingToolbar( overflowInitialMenu = AppBarMenu.Main showOverflowMenu = !showOverflowMenu keyboardController?.hide() + onCloseEmoteMenu() }) { Icon( imageVector = Icons.Default.MoreVert, 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 index f14b664cc..ffee22acb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -612,6 +612,8 @@ fun MainScreen( onAddChannelTooltipDismiss = featureTourViewModel::onToolbarHintDismissed, onSkipTour = featureTourViewModel::skipTour, keyboardHeightDp = with(density) { currentImeHeight.toDp() }, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + onCloseEmoteMenu = { chatInputViewModel.setEmoteMenuOpen(false) }, streamToolbarAlpha = streamState.effectiveAlpha, modifier = toolbarModifier, ) From 7af893e26c13e7890d5c38b975d505f30ff9e48c Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 00:09:35 +0200 Subject: [PATCH 303/349] chore: Bump version to 4.0.14 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f399433b5..65941dcfa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40013 - versionName = "4.0.13" + versionCode = 40014 + versionName = "4.0.14" } androidResources { generateLocaleConfig = true } From e5b0b3228f93c4dc46751ec3b043b1a402b1ce8a Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 20:04:48 +0200 Subject: [PATCH 304/349] feat(settings): Add toggle to disable clear input button --- .../dankchat/preferences/appearance/AppearanceSettings.kt | 1 + .../preferences/appearance/AppearanceSettingsDataStore.kt | 4 ++++ .../preferences/appearance/AppearanceSettingsScreen.kt | 8 ++++++++ .../preferences/appearance/AppearanceSettingsState.kt | 4 ++++ .../preferences/appearance/AppearanceSettingsViewModel.kt | 1 + .../com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt | 2 +- .../com/flxrs/dankchat/ui/main/input/ChatInputUiState.kt | 1 + .../flxrs/dankchat/ui/main/input/ChatInputViewModel.kt | 8 +++++--- app/src/main/res/values-b+zh+Hant+TW/strings.xml | 1 + app/src/main/res/values-be-rBY/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de-rDE/strings.xml | 1 + app/src/main/res/values-en-rAU/strings.xml | 1 + app/src/main/res/values-en-rGB/strings.xml | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu-rHU/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-kk-rKZ/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sr/strings.xml | 1 + app/src/main/res/values-tr-rTR/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 32 files changed, 49 insertions(+), 4 deletions(-) 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 17aed96ad..6401d0a3f 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 @@ -32,6 +32,7 @@ data class AppearanceSettings( val showChips: Boolean = true, val showChangelogs: Boolean = true, val showCharacterCounter: Boolean = false, + val showClearInputButton: Boolean = true, val swipeNavigation: Boolean = true, val inputActions: List = listOf( 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 75ca1815f..28e480a67 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 @@ -130,6 +130,10 @@ class AppearanceSettingsDataStore( settings .map { it.showCharacterCounter } .distinctUntilChanged() + val showClearInputButton = + settings + .map { it.showClearInputButton } + .distinctUntilChanged() fun current() = currentSettings.value 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 index 1b2339e73..1794829de 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -73,6 +73,7 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.F 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.SwipeNavigation import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.Theme import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.TrueDarkTheme @@ -151,6 +152,7 @@ private fun AppearanceSettingsContent( ComponentsCategory( autoDisableInput = settings.autoDisableInput, showCharacterCounter = settings.showCharacterCounter, + showClearInputButton = settings.showClearInputButton, swipeNavigation = settings.swipeNavigation, onInteraction = onInteraction, ) @@ -163,6 +165,7 @@ private fun AppearanceSettingsContent( private fun ComponentsCategory( autoDisableInput: Boolean, showCharacterCounter: Boolean, + showClearInputButton: Boolean, swipeNavigation: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit, ) { @@ -180,6 +183,11 @@ private fun ComponentsCategory( 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_swipe_navigation_title), summary = stringResource(R.string.preference_swipe_navigation_summary), 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 index 7637a8415..cc5f08497 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -39,6 +39,10 @@ sealed interface AppearanceSettingsInteraction { val value: Boolean, ) : AppearanceSettingsInteraction + data class ShowClearInputButton( + val value: Boolean, + ) : AppearanceSettingsInteraction + data class SwipeNavigation( val value: Boolean, ) : AppearanceSettingsInteraction 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 fd9da2844..ce7b5bae6 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 @@ -35,6 +35,7 @@ class AppearanceSettingsViewModel( 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.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) } 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 index 01c6cd0f3..2ebddcb13 100644 --- 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 @@ -285,7 +285,7 @@ fun ChatInputLayout( } } AnimatedVisibility( - visible = enabled && textFieldState.text.isNotEmpty(), + visible = enabled && uiState.showClearInputButton && textFieldState.text.isNotEmpty(), enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start), ) { 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 index 4b5372c61..d0b14a687 100644 --- 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 @@ -28,6 +28,7 @@ data class ChatInputUiState( val helperText: HelperText = HelperText(), val isWhisperTabActive: Boolean = false, val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, + val showClearInputButton: Boolean = true, val userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, ) 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 index 3b150330c..3f6e31b4a 100644 --- 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 @@ -227,10 +227,10 @@ class ChatInputViewModel( chatConnector.getConnectionState(channel) } }, - appearanceSettingsDataStore.settings.map { it.autoDisableInput to it.showCharacterCounter }, + appearanceSettingsDataStore.settings.map { Triple(it.autoDisableInput, it.showCharacterCounter, it.showClearInputButton) }, preferenceStore.isLoggedInFlow, - ) { text, suggestions, activeChannel, connectionState, (autoDisableInput, showCharacterCounter), isLoggedIn -> - UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput, showCharacterCounter) + ) { text, suggestions, activeChannel, connectionState, (autoDisableInput, showCharacterCounter, showClearInputButton), isLoggedIn -> + UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput, showCharacterCounter, showClearInputButton) } val replyStateFlow = @@ -335,6 +335,7 @@ class ChatInputViewModel( else -> CharacterCounterState.Hidden }, + showClearInputButton = deps.showClearInputButton, userLongClickBehavior = userLongClickBehavior, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()).also { _uiState = it } @@ -592,6 +593,7 @@ private data class UiDependencies( val isLoggedIn: Boolean, val autoDisableInput: Boolean, val showCharacterCounter: Boolean, + val showClearInputButton: Boolean, ) private data class ReplyState( 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 05da9619d..55ce84573 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 @@ -500,6 +500,7 @@ 顯示用於切換全螢幕、實況與聊天室模式的微項動作 顯示字元計數器 在輸入框中顯示字碼數 + 顯示清除輸入按鈕 媒體上傳者 設定上傳者 近期上傳 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 77f8090c6..1c91e37cd 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -366,6 +366,7 @@ Адлюстроўваць пераключальнікі для поўнаэкраннага рэжыму і налады рэжымаў чату Паказваць лічыльнік сімвалаў Адлюстроўвае колькасць кодавых пунктаў у полі ўводу + Паказваць кнопку ачысткі ўводу Загрузчык медыя Наладзіць загрузчык Нядаўнія загрузкі diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 23a2b4383..9e518509b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -370,6 +370,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 Pujador de media Configurar pujador Pujades recents diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 37fd0c6c8..007f115c3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -372,6 +372,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 Nahrávač médií Konfigurace nahrávače Nedávná nahrání diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index ec593eadf..af1568494 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -363,6 +363,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 Medienupload Upload konfigurieren Letzte Uploads diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 12d78e4af..38cd5d2e4 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -319,6 +319,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 Media uploader Configure uploader Recent uploads diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 86f8d823d..41596b37e 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -319,6 +319,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 Media uploader Configure uploader Recent uploads diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a93b77e83..1fd99cd2c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -356,6 +356,7 @@ 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 Media uploader Configure uploader Recent uploads diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 111e58ce3..ff80605c8 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -367,6 +367,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 Subidor de multimedia Configurar subidor Subidas recientes diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 6f4eff887..44afaaaa9 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -362,6 +362,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 Median lähettäjä Määritä lähettäjä Viimeaikaiset lataukset diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 6cb6fb32d..93a87fdf6 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -366,6 +366,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 Mise en ligne de fichiers Configurer la mise en ligne de fichiers personnalisé Fichiers récents diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index bafb9c524..c6af00ebf 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -355,6 +355,7 @@ 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 Média feltöltő Feltöltő konfigurálása Legutóbbi feltőltések diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f1b49257b..66e948aa6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -360,6 +360,7 @@ 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 Caricatore multimediale Configura caricatore Caricamenti recenti diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index d4677e161..6545750ce 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -349,6 +349,7 @@ 全画面表示、ストリーム、チャットモードの調整のためのチップを表示します 文字数カウンターを表示 入力フィールドにコードポイント数を表示します + 入力クリアボタンを表示 メディアアップローダー アップローダーを設定 最近のアップロード diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 0e81bc5a7..1ae69154d 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -498,6 +498,7 @@ Толық экранды, ағындарды қосуға және чат режимдерін реттеуге арналған чиптерді көрсетеді Таңбалар санағышын көрсету Енгізу өрісінде код нүктелерінің санын көрсетеді + Енгізуді тазалау түймесін көрсету Медиа жүктеп салушы Жүктеуді баптау Соңғы жүктеулер diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 3cda022f2..53be81716 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -498,6 +498,7 @@ ଫୁଲ୍ ସ୍କ୍ରିନ୍, ଷ୍ଟ୍ରିମ୍ ଏବଂ ଚାଟ୍ ମୋଡ୍ ସଜାଡିବା ପାଇଁ ଚିପ୍ସ ପ୍ରଦର୍ଶନ କରେ | ଅକ୍ଷର ଗଣକ ଦେଖାନ୍ତୁ ଇନପୁଟ୍ ଫିଲ୍ଡରେ କୋଡ୍ ପଏଣ୍ଟ ଗଣନା ପ୍ରଦର୍ଶନ କରେ + ଇନପୁଟ୍ ସଫା ବଟନ୍ ଦେଖାନ୍ତୁ ମିଡିଆ ଅପଲୋଡର୍ | ଅପଲୋଡର୍ କୁ ବିନ୍ୟାସ କରନ୍ତୁ | ସମ୍ପ୍ରତି ଅପଲୋଡ୍ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 282345873..0166be30f 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -369,6 +369,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 Przesyłacz media Przesyłacz konfiguracji Ostatnio udostępnione diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index db3fec598..4f0704b45 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -361,6 +361,7 @@ 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 Carregador de mídia Configurar carregador Envios recentes diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index eedede5fb..fe6fbc989 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -361,6 +361,7 @@ 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 Carregador de mídia Configurar carregador Carregamentos recentes diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index b4cafd52e..e21dadbba 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -371,6 +371,7 @@ Отображать переключатели для полноэкранного режима, трансляций и настройки режимов чата Показывать счётчик символов Отображает количество кодовых точек в поле ввода + Показывать кнопку очистки ввода Загрузчик медиа Настроить загрузчик Недавние загрузки diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index ee0ab8d95..464e72d3a 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -462,6 +462,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Приказује чипове за пребацивање целог екрана, стримова и подешавање режима чата Prikaži brojač karaktera Prikazuje broj kodnih tačaka u polju za unos + Prikaži dugme za brisanje unosa Отпремање медија Подеси отпремање Недавна отпремања diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index aa4ca0b3d..2775af4b3 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -361,6 +361,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 Medya yükleyici Yükleyiciyi ayarla Son yüklemeler diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 80583dd90..f61fb4eaa 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -373,6 +373,7 @@ Показує кнопки для перемикання повноекранного режиму та плеєру і змінення режимів чату Показувати лічильник символів Відображає кількість кодових точок у полі введення + Показувати кнопку очищення введення Сервіс завантаження медіа Налаштувати сервіс завантаження медіа Останні завантаження diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 137366a2a..d0954daa8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -573,6 +573,7 @@ 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 Media uploader Configure uploader Recent uploads From 13dd0e209bcc375585f2d150c3c65eb6aa32ecc2 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 20:54:03 +0200 Subject: [PATCH 305/349] feat(log-viewer): Add multi-select copy mode for log lines --- .../preferences/chat/ChatSettingsDataStore.kt | 1 + .../flxrs/dankchat/ui/log/LogViewerSheet.kt | 223 +++++++++++++----- .../dankchat/ui/log/LogViewerViewModel.kt | 31 +++ .../main/res/values-b+zh+Hant+TW/strings.xml | 3 + app/src/main/res/values-be-rBY/strings.xml | 3 + app/src/main/res/values-ca/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-en-rAU/strings.xml | 3 + app/src/main/res/values-en-rGB/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 3 + app/src/main/res/values-fi-rFI/strings.xml | 3 + app/src/main/res/values-fr-rFR/strings.xml | 3 + app/src/main/res/values-hu-rHU/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja-rJP/strings.xml | 3 + app/src/main/res/values-kk-rKZ/strings.xml | 3 + app/src/main/res/values-or-rIN/strings.xml | 3 + app/src/main/res/values-pl-rPL/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt-rPT/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values-sr/strings.xml | 3 + app/src/main/res/values-tr-rTR/strings.xml | 3 + app/src/main/res/values-uk-rUA/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 27 files changed, 272 insertions(+), 55 deletions(-) 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 b506ecce8..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 @@ -69,6 +69,7 @@ class ChatSettingsDataStore( @Suppress("DEPRECATION") ChatPreferenceKeys.SupibotSuggestions, -> { + @Suppress("DEPRECATION") acc.copy(supibotSuggestions = value.booleanOrDefault(acc.supibotSuggestions)) } 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 index 71ee57def..5af115587 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerSheet.kt @@ -1,6 +1,8 @@ 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 @@ -8,8 +10,11 @@ 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 @@ -35,6 +40,8 @@ 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 @@ -57,6 +64,7 @@ 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 @@ -67,6 +75,8 @@ 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 @@ -88,13 +98,18 @@ 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( @@ -123,7 +138,10 @@ fun LogViewerSheet(onDismiss: () -> Unit) { progress.collect { event -> backProgress = event.progress } - onDismiss() + when { + isSelectionActive -> viewModel.clearSelection() + else -> onDismiss() + } } catch (_: CancellationException) { backProgress = 0f } @@ -191,8 +209,12 @@ fun LogViewerSheet(onDismiss: () -> Unit) { itemsIndexed( items = state.lines, key = { index, _ -> index }, - ) { _, line -> - LogLineItem(line = line) + ) { index, line -> + LogLineItem( + line = line, + isSelected = index in selectedIndices, + onClick = { viewModel.toggleSelection(index) }, + ) } } @@ -224,6 +246,8 @@ fun LogViewerSheet(onDismiss: () -> Unit) { statusBarHeight = statusBarHeight, sheetBackgroundColor = sheetBackgroundColor, levelFilter = state.levelFilter, + isSelectionActive = isSelectionActive, + selectedCount = selectedIndices.size, onBack = onDismiss, onLevelFilter = viewModel::setLevelFilter, onShare = { @@ -236,6 +260,14 @@ fun LogViewerSheet(onDismiss: () -> Unit) { } 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), ) @@ -263,7 +295,10 @@ fun LogViewerSheet(onDismiss: () -> Unit) { .padding(bottom = 8.dp) .padding(horizontal = 8.dp), ) { - LogSearchToolbar(state = viewModel.searchFieldState) + LogSearchToolbar( + state = viewModel.searchFieldState, + enabled = !isSelectionActive, + ) } } } @@ -274,9 +309,13 @@ private fun LogViewerToolbar( 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( @@ -301,62 +340,115 @@ private fun LogViewerToolbar( .padding(horizontal = 8.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerLow, + 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, ) { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - } + 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 = 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, + ) { + Text( + text = stringResource(R.string.log_viewer_selected_count, 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 = onShare) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.log_viewer_share), - ) + 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 - 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, - ), - ) + 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, + ), + ) + } } } } @@ -365,13 +457,29 @@ private fun LogViewerToolbar( } @Composable -private fun LogLineItem(line: LogLine) { +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.padding(horizontal = 8.dp, vertical = 1.dp), + modifier = + Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ).padding(horizontal = 8.dp, vertical = 1.dp), ) } @@ -419,7 +527,10 @@ private fun formatLogLine(line: LogLine): AnnotatedString { } @Composable -private fun LogSearchToolbar(state: TextFieldState) { +private fun LogSearchToolbar( + state: TextFieldState, + enabled: Boolean = true, +) { val keyboardController = LocalSoftwareKeyboardController.current val textFieldColors = TextFieldDefaults.colors( @@ -427,10 +538,12 @@ private fun LogSearchToolbar(state: TextFieldState) { 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 = { 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 index c370b50c9..263b8b3aa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerViewModel.kt @@ -16,9 +16,11 @@ 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.android.annotation.KoinViewModel import java.io.File @@ -31,6 +33,8 @@ class LogViewerViewModel( private val _levelFilter = MutableStateFlow(null) private val _selectedFileName = MutableStateFlow("") + private val _selectedIndices = MutableStateFlow>(emptySet()) + val selectedIndices: StateFlow> = _selectedIndices.asStateFlow() init { val route = savedStateHandle.toRoute() @@ -40,6 +44,12 @@ class LogViewerViewModel( .firstOrNull() ?.name .orEmpty() + + viewModelScope.launch { + combine(_levelFilter, snapshotFlow { searchFieldState.text.toString() }) { _, _ -> }.collect { + _selectedIndices.value = emptySet() + } + } } val state: StateFlow = combine( @@ -72,6 +82,27 @@ class LogViewerViewModel( } } + 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 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 55ce84573..20de2ba5c 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 @@ -855,5 +855,8 @@ 檢視日誌 沒有可用的日誌檔案 搜尋日誌 + 已選取 %1$d 項 + 複製選取的日誌 + 清除選取 捲動至底部 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 1c91e37cd..e4bebcf3f 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -873,5 +873,8 @@ Праглядзець логі Няма даступных файлаў логаў Пошук у логах + Выбрана: %1$d + Капіраваць выбраныя логі + Ачысціць выбар Пракруціць уніз diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9e518509b..23bd0b333 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -898,5 +898,8 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Mostra els registres No hi ha fitxers de registre disponibles Cerca als registres + %1$d seleccionats + Copia els registres seleccionats + Esborra la selecció Desplaça cap avall diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 007f115c3..978cc8d6b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -873,5 +873,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Zobrazit logy Žádné soubory logů nejsou dostupné Hledat v logách + %1$d vybráno + Kopírovat vybrané logy + Zrušit výběr Posunout dolů diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index af1568494..b1337e51b 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -872,5 +872,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Protokolle anzeigen Keine Protokolldateien verfügbar Protokolle durchsuchen + %1$d ausgewählt + Ausgewählte Protokolle kopieren + Auswahl aufheben Nach unten scrollen diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 38cd5d2e4..c97677185 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -851,5 +851,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/View logs No log files available Search logs + %1$d selected + Copy selected logs + Clear selection Scroll to bottom diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 41596b37e..d2df22c86 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -851,5 +851,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/View logs No log files available Search logs + %1$d selected + Copy selected logs + Clear selection Scroll to bottom diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 1fd99cd2c..5d3fc8b3d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -866,5 +866,8 @@ View logs No log files available Search logs + %1$d selected + Copy selected logs + Clear selection Scroll to bottom diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index ff80605c8..1c192a88c 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -882,5 +882,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Ver registros No hay archivos de registro disponibles Buscar en los registros + %1$d seleccionados + Copiar registros seleccionados + Borrar selección Desplazar hacia abajo diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 44afaaaa9..050b9d92c 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -873,5 +873,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Näytä lokit Lokitiedostoja ei ole saatavilla Hae lokeista + %1$d valittu + Kopioi valitut lokit + Tyhjennä valinta Vieritä alas diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 93a87fdf6..a5ab405bb 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -866,5 +866,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Voir les journaux Aucun fichier journal disponible Rechercher dans les journaux + %1$d sélectionnés + Copier les journaux sélectionnés + Effacer la sélection Défiler vers le bas diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index c6af00ebf..1919e50ea 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -849,5 +849,8 @@ Naplók megtekintése Nincsenek elérhető naplófájlok Keresés a naplókban + %1$d kiválasztva + Kiválasztott naplók másolása + Kijelölés törlése Görgetés az aljára diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 66e948aa6..613552fa1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -849,5 +849,8 @@ Visualizza log Nessun file di log disponibile Cerca nei log + %1$d selezionati + Copia log selezionati + Cancella selezione Scorri verso il basso diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 6545750ce..accf91cd3 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -829,5 +829,8 @@ ログを表示 ログファイルがありません ログを検索 + %1$d 件選択中 + 選択したログをコピー + 選択を解除 一番下にスクロール diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 1ae69154d..2ee817ff7 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -854,5 +854,8 @@ Журналдарды көру Журнал файлдары жоқ Журналдарды іздеу + %1$d таңдалды + Таңдалған журналдарды көшіру + Таңдауды тазалау Төменге айналдыру diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 53be81716..38a354fdf 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -854,5 +854,8 @@ ଲଗ୍ ଦେଖନ୍ତୁ କୌଣସି ଲଗ୍ ଫାଇଲ୍ ଉପಲବ୍ଧ ନାହିଁ ଲଗ୍ ଖୋଜନ୍ତୁ + %1$d ଚୟନ ହୋଇଛି + ଚୟନିତ ଲଗ୍ କପି କରନ୍ତୁ + ଚୟନ ସଫା କରନ୍ତୁ ତଳକୁ ସ୍କ୍ରୋଲ୍ କରନ୍ତୁ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 0166be30f..1777a14ca 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -891,5 +891,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Wyświetl logi Brak dostępnych plików logów Szukaj w logach + Wybrano %1$d + Kopiuj wybrane logi + Wyczyść zaznaczenie Przewiń na dół diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4f0704b45..ba7f4483e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -861,5 +861,8 @@ Ver logs Nenhum arquivo de log disponível Pesquisar nos logs + %1$d selecionados + Copiar logs selecionados + Limpar seleção Rolar para baixo diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index fe6fbc989..8d6a4680a 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -851,5 +851,8 @@ Ver registos Nenhum ficheiro de registo disponível Pesquisar nos registos + %1$d selecionados + Copiar registos selecionados + Limpar seleção Deslocar para baixo diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index e21dadbba..9a54385aa 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -878,5 +878,8 @@ Просмотреть журналы Файлы журналов отсутствуют Поиск по журналам + Выбрано: %1$d + Копировать выбранные журналы + Снять выделение Прокрутить вниз diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 464e72d3a..334746a1e 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -902,5 +902,8 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Прегледај логове Нема доступних лог фајлова Претражи логове + %1$d изабрано + Копирај изабране логове + Обриши избор Помери на дно diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 2775af4b3..93741d304 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -870,5 +870,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Günlükleri görüntüle Kullanılabilir günlük dosyası yok Günlüklerde ara + %1$d seçili + Seçili günlükleri kopyala + Seçimi temizle En alta kaydır diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index f61fb4eaa..43194ca74 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -875,5 +875,8 @@ Переглянути журнали Файли журналів відсутні Пошук у журналах + Вибрано: %1$d + Копіювати вибрані журнали + Скасувати вибір Прокрутити донизу diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0954daa8..838c93360 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -728,6 +728,9 @@ View logs No log files available Search logs + %1$d selected + Copy selected logs + Clear selection Scroll to bottom Filter by username Messages containing links From 30c5962c22cc3d5002bf28bbd80ff86579aeb398 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 21:07:04 +0200 Subject: [PATCH 306/349] chore: Suppress deprecation warning and PictureInPictureIssue lint --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65941dcfa..1b5f8ab04 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,7 @@ android { disable += "RestrictedApi" disable += "UnusedResources" disable += "ObsoleteSdkInt" + disable += "PictureInPictureIssue" } } From 6c0abc1c01e6289ed3f080c41dced4f1f346c230 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 21:20:34 +0200 Subject: [PATCH 307/349] fix(toolbar): Align quick switcher text styles with tab unread/mention state --- .../com/flxrs/dankchat/ui/main/FloatingToolbar.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 index 35bb9bd2b..0b93469bb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -498,6 +498,7 @@ fun FloatingToolbar( ) { tabState.tabs.forEachIndexed { index, tab -> val isSelected = index == selectedIndex + val hasActivity = tab.mentionCount > 0 || tab.hasUnread Row( modifier = Modifier @@ -516,9 +517,15 @@ fun FloatingToolbar( color = when { isSelected -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface + hasActivity -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = + when { + isSelected -> FontWeight.Bold + hasActivity -> FontWeight.SemiBold + else -> FontWeight.Normal }, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, ) From cbf6dd6b26aae355c9cea5ea72c65d19d482dac2 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 21:46:02 +0200 Subject: [PATCH 308/349] feat(settings): Add toggle to hide send button, split input settings into own group --- .../appearance/AppearanceSettings.kt | 1 + .../appearance/AppearanceSettingsDataStore.kt | 4 +++ .../appearance/AppearanceSettingsScreen.kt | 30 ++++++++++++++++--- .../appearance/AppearanceSettingsState.kt | 4 +++ .../appearance/AppearanceSettingsViewModel.kt | 1 + .../dankchat/ui/main/input/ChatInputLayout.kt | 26 +++++++++------- .../ui/main/input/ChatInputUiState.kt | 1 + .../ui/main/input/ChatInputViewModel.kt | 24 +++++++++------ .../main/res/values-b+zh+Hant+TW/strings.xml | 2 ++ app/src/main/res/values-be-rBY/strings.xml | 2 ++ app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 2 ++ app/src/main/res/values-de-rDE/strings.xml | 2 ++ app/src/main/res/values-en-rAU/strings.xml | 2 ++ app/src/main/res/values-en-rGB/strings.xml | 2 ++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values-es-rES/strings.xml | 2 ++ app/src/main/res/values-fi-rFI/strings.xml | 2 ++ app/src/main/res/values-fr-rFR/strings.xml | 2 ++ app/src/main/res/values-hu-rHU/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja-rJP/strings.xml | 2 ++ app/src/main/res/values-kk-rKZ/strings.xml | 2 ++ app/src/main/res/values-or-rIN/strings.xml | 2 ++ app/src/main/res/values-pl-rPL/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt-rPT/strings.xml | 2 ++ app/src/main/res/values-ru-rRU/strings.xml | 2 ++ app/src/main/res/values-sr/strings.xml | 2 ++ app/src/main/res/values-tr-rTR/strings.xml | 2 ++ app/src/main/res/values-uk-rUA/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 32 files changed, 116 insertions(+), 23 deletions(-) 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 6401d0a3f..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 @@ -33,6 +33,7 @@ data class AppearanceSettings( val showChangelogs: Boolean = true, val showCharacterCounter: Boolean = false, val showClearInputButton: Boolean = true, + val showSendButton: Boolean = true, val swipeNavigation: Boolean = true, val inputActions: List = listOf( 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 28e480a67..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 @@ -134,6 +134,10 @@ class AppearanceSettingsDataStore( 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/AppearanceSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt index 1794829de..9aa9fe875 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -74,6 +74,7 @@ import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.K 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 @@ -149,10 +150,15 @@ private fun AppearanceSettingsContent( onInteraction = onInteraction, ) HorizontalDivider(thickness = Dp.Hairline) - ComponentsCategory( + 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, ) @@ -162,15 +168,15 @@ private fun AppearanceSettingsContent( } @Composable -private fun ComponentsCategory( +private fun InputCategory( autoDisableInput: Boolean, showCharacterCounter: Boolean, showClearInputButton: Boolean, - swipeNavigation: Boolean, + showSendButton: Boolean, onInteraction: (AppearanceSettingsInteraction) -> Unit, ) { PreferenceCategory( - title = stringResource(R.string.preference_components_group_title), + title = stringResource(R.string.preference_input_group_title), ) { SwitchPreferenceItem( title = stringResource(R.string.preference_auto_disable_input_title), @@ -188,6 +194,22 @@ private fun ComponentsCategory( 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), 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 index cc5f08497..8126ab809 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -43,6 +43,10 @@ sealed interface AppearanceSettingsInteraction { val value: Boolean, ) : AppearanceSettingsInteraction + data class ShowSendButton( + val value: Boolean, + ) : AppearanceSettingsInteraction + data class SwipeNavigation( val value: Boolean, ) : AppearanceSettingsInteraction 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 ce7b5bae6..f155ca0f4 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 @@ -36,6 +36,7 @@ class AppearanceSettingsViewModel( 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) } 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 index 2ebddcb13..3f26ddc5e 100644 --- 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 @@ -336,6 +336,7 @@ fun ChatInputLayout( isEmoteMenuOpen = isEmoteMenuOpen, enabled = enabled, showQuickActions = showQuickActions, + showSendButton = uiState.showSendButton, tourState = tourState, quickActionsExpanded = quickActionsExpanded, canSend = canSend, @@ -657,6 +658,7 @@ private fun InputActionsRow( isEmoteMenuOpen: Boolean, enabled: Boolean, showQuickActions: Boolean, + showSendButton: Boolean, tourState: TourOverlayState, quickActionsExpanded: Boolean, canSend: Boolean, @@ -686,8 +688,8 @@ private fun InputActionsRow( .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), ) { val iconSize = 40.dp - // Fixed slots: emote + overflow + send (+ whisper if present) - val fixedSlots = 1 + (if (showQuickActions) 1 else 0) + (if (onNewWhisper != null) 1 else 0) + 1 + // 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() @@ -734,6 +736,7 @@ private fun InputActionsRow( visibleActions = visibleActions, iconSize = iconSize, showQuickActions = showQuickActions, + showSendButton = showSendButton, tourState = tourState, quickActionsExpanded = quickActionsExpanded, canSend = canSend, @@ -768,6 +771,7 @@ private fun EndAlignedActionGroup( visibleActions: ImmutableList, iconSize: Dp, showQuickActions: Boolean, + showSendButton: Boolean, tourState: TourOverlayState, quickActionsExpanded: Boolean, canSend: Boolean, @@ -857,14 +861,16 @@ private fun EndAlignedActionGroup( } // Send Button (Right) - Spacer(modifier = Modifier.width(4.dp)) - SendButton( - enabled = canSend, - isRepeatedSendEnabled = isRepeatedSendEnabled, - onSend = onSend, - onRepeatedSendChange = onRepeatedSendChange, - modifier = Modifier.size(44.dp), - ) + if (showSendButton) { + Spacer(modifier = Modifier.width(4.dp)) + SendButton( + enabled = canSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onSend = onSend, + onRepeatedSendChange = onRepeatedSendChange, + modifier = Modifier.size(44.dp), + ) + } } @Composable 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 index d0b14a687..bc7d564d6 100644 --- 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 @@ -29,6 +29,7 @@ data class ChatInputUiState( val isWhisperTabActive: Boolean = false, val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, val showClearInputButton: Boolean = true, + val showSendButton: Boolean = true, val userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, ) 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 index 3f6e31b4a..1a3459e77 100644 --- 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 @@ -227,10 +227,10 @@ class ChatInputViewModel( chatConnector.getConnectionState(channel) } }, - appearanceSettingsDataStore.settings.map { Triple(it.autoDisableInput, it.showCharacterCounter, it.showClearInputButton) }, + appearanceSettingsDataStore.settings.map { InputSettings(it.autoDisableInput, it.showCharacterCounter, it.showClearInputButton, it.showSendButton) }, preferenceStore.isLoggedInFlow, - ) { text, suggestions, activeChannel, connectionState, (autoDisableInput, showCharacterCounter, showClearInputButton), isLoggedIn -> - UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, autoDisableInput, showCharacterCounter, showClearInputButton) + ) { text, suggestions, activeChannel, connectionState, inputSettings, isLoggedIn -> + UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, inputSettings) } val replyStateFlow = @@ -266,7 +266,7 @@ class ChatInputViewModel( 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.autoDisableInput + val canTypeInConnectionState = deps.connectionState == ConnectionState.CONNECTED || !deps.inputSettings.autoDisableInput val inputState = when (deps.connectionState) { @@ -328,14 +328,15 @@ class ChatInputViewModel( isWhisperTabActive = isWhisperTabActive, characterCounter = when { - deps.showCharacterCounter -> CharacterCounterState.Visible( + deps.inputSettings.showCharacterCounter -> CharacterCounterState.Visible( text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, ) else -> CharacterCounterState.Hidden }, - showClearInputButton = deps.showClearInputButton, + showClearInputButton = deps.inputSettings.showClearInputButton, + showSendButton = deps.inputSettings.showSendButton, userLongClickBehavior = userLongClickBehavior, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()).also { _uiState = it } @@ -585,15 +586,20 @@ private data class SuggestionInput( 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 autoDisableInput: Boolean, - val showCharacterCounter: Boolean, - val showClearInputButton: Boolean, + val inputSettings: InputSettings, ) private data class ReplyState( 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 20de2ba5c..e29e207f8 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 @@ -501,6 +501,8 @@ 顯示字元計數器 在輸入框中顯示字碼數 顯示清除輸入按鈕 + 顯示傳送按鈕 + 輸入 媒體上傳者 設定上傳者 近期上傳 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index e4bebcf3f..7594b99c3 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -367,6 +367,8 @@ Паказваць лічыльнік сімвалаў Адлюстроўвае колькасць кодавых пунктаў у полі ўводу Паказваць кнопку ачысткі ўводу + Паказваць кнопку адпраўкі + Увод Загрузчык медыя Наладзіць загрузчык Нядаўнія загрузкі diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 23bd0b333..2af31bd12 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -371,6 +371,8 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 978cc8d6b..66532ad3b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -373,6 +373,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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í diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index b1337e51b..6e20122dc 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -364,6 +364,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index c97677185..960e15742 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -320,6 +320,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index d2df22c86..e1ebc9e43 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -320,6 +320,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 5d3fc8b3d..512a5f49a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -357,6 +357,8 @@ 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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 1c192a88c..cd0c1e8c0 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -368,6 +368,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 050b9d92c..da29b80f8 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -363,6 +363,8 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index a5ab405bb..b604ba3cd 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -367,6 +367,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 1919e50ea..c063575cd 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -356,6 +356,8 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 613552fa1..56cba10e1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -361,6 +361,8 @@ 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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index accf91cd3..32824328e 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -350,6 +350,8 @@ 文字数カウンターを表示 入力フィールドにコードポイント数を表示します 入力クリアボタンを表示 + 送信ボタンを表示 + 入力 メディアアップローダー アップローダーを設定 最近のアップロード diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 2ee817ff7..53fa87ea3 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -499,6 +499,8 @@ Таңбалар санағышын көрсету Енгізу өрісінде код нүктелерінің санын көрсетеді Енгізуді тазалау түймесін көрсету + Жіберу түймесін көрсету + Енгізу Медиа жүктеп салушы Жүктеуді баптау Соңғы жүктеулер diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 38a354fdf..111c71a2f 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -499,6 +499,8 @@ ଅକ୍ଷର ଗଣକ ଦେଖାନ୍ତୁ ଇନପୁଟ୍ ଫିଲ୍ଡରେ କୋଡ୍ ପଏଣ୍ଟ ଗଣନା ପ୍ରଦର୍ଶନ କରେ ଇନପୁଟ୍ ସଫା ବଟନ୍ ଦେଖାନ୍ତୁ + ପଠାଇବା ବଟନ୍ ଦେଖାନ୍ତୁ + ଇନପୁଟ୍ ମିଡିଆ ଅପଲୋଡର୍ | ଅପଲୋଡର୍ କୁ ବିନ୍ୟାସ କରନ୍ତୁ | ସମ୍ପ୍ରତି ଅପଲୋଡ୍ | diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 1777a14ca..0e87be340 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -370,6 +370,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ba7f4483e..7bf9b8776 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -362,6 +362,8 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 8d6a4680a..d923597fa 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -362,6 +362,8 @@ 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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 9a54385aa..95fe0c110 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -372,6 +372,8 @@ Показывать счётчик символов Отображает количество кодовых точек в поле ввода Показывать кнопку очистки ввода + Показывать кнопку отправки + Ввод Загрузчик медиа Настроить загрузчик Недавние загрузки diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 334746a1e..d69d6d256 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -463,6 +463,8 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/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 Отпремање медија Подеси отпремање Недавна отпремања diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 93741d304..7b07603f1 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -362,6 +362,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 43194ca74..c40407821 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -374,6 +374,8 @@ Показувати лічильник символів Відображає кількість кодових точок у полі введення Показувати кнопку очищення введення + Показувати кнопку надсилання + Введення Сервіс завантаження медіа Налаштувати сервіс завантаження медіа Останні завантаження diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 838c93360..981f31f47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,6 +574,8 @@ 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 From 9fb76162182df662c43d6f67399e197efe979f1a Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 22:00:34 +0200 Subject: [PATCH 309/349] feat(emotes): Track emote usage when user types emote codes manually --- .../dankchat/data/repo/emote/EmoteRepository.kt | 12 ++++++++++++ .../dankchat/ui/main/input/ChatInputViewModel.kt | 11 +++++++++++ 2 files changed, 23 insertions(+) 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 326ad5456..307980e8b 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 @@ -137,6 +137,18 @@ class EmoteRepository( } } + 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, 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 index 1a3459e77..44b4f2aff 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -72,6 +73,7 @@ class ChatInputViewModel( 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, @@ -357,11 +359,20 @@ class ChatInputViewModel( 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, From d72df7796c962d77a36205be4ba2b8c0ad058fa8 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 5 Apr 2026 22:25:56 +0200 Subject: [PATCH 310/349] fix(logging): Use date-based log file names, add file icons to log picker --- app/src/main/assets/logback.xml | 1 - .../developer/DeveloperSettingsScreen.kt | 21 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/main/assets/logback.xml b/app/src/main/assets/logback.xml index 4f61069ab..9f12aa3f7 100644 --- a/app/src/main/assets/logback.xml +++ b/app/src/main/assets/logback.xml @@ -11,7 +11,6 @@ - ${DATA_DIR}/logs/dankchat.log %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 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 index a9bb6cf19..4df13d076 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -15,6 +15,7 @@ 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 @@ -25,6 +26,7 @@ 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.Description import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -186,9 +188,9 @@ private fun DeveloperSettingsContent( ) } else { logFiles.forEach { file -> - Text( - text = file.name, - style = MaterialTheme.typography.bodyLarge, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier .fillMaxWidth() @@ -199,7 +201,18 @@ private fun DeveloperSettingsContent( } 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)) From ab9b31fb737fb58346ce47aa0e04e162806a85b6 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 10:55:18 +0200 Subject: [PATCH 311/349] feat(crash): Add crash reporting with post-crash sheet, viewer, email and chat report options --- .../com/flxrs/dankchat/DankChatApplication.kt | 17 +- .../dankchat/data/repo/crash/CrashData.kt | 10 + .../dankchat/data/repo/crash/CrashEntry.kt | 18 + .../dankchat/data/repo/crash/CrashHandler.kt | 106 ++++++ .../data/repo/crash/CrashRepository.kt | 100 ++++++ .../dankchat/data/repo/log/LogRepository.kt | 2 + .../developer/DeveloperSettingsScreen.kt | 132 +++++++ .../ui/crash/CrashEmailConfirmationSheet.kt | 144 ++++++++ .../dankchat/ui/crash/CrashViewerSheet.kt | 296 ++++++++++++++++ .../dankchat/ui/crash/CrashViewerViewModel.kt | 54 +++ .../flxrs/dankchat/ui/main/MainActivity.kt | 12 + .../flxrs/dankchat/ui/main/MainDestination.kt | 5 + .../dankchat/ui/main/dialog/DialogState.kt | 2 + .../ui/main/dialog/DialogStateViewModel.kt | 26 ++ .../ui/main/dialog/MainScreenDialogs.kt | 329 ++++++++++++++++++ .../utils/compose/ConfirmationBottomSheet.kt | 4 + .../main/res/values-b+zh+Hant+TW/strings.xml | 23 +- app/src/main/res/values-be-rBY/strings.xml | 23 +- app/src/main/res/values-ca/strings.xml | 23 +- app/src/main/res/values-cs/strings.xml | 23 +- app/src/main/res/values-de-rDE/strings.xml | 23 +- app/src/main/res/values-en-rAU/strings.xml | 23 +- app/src/main/res/values-en-rGB/strings.xml | 23 +- app/src/main/res/values-en/strings.xml | 23 +- app/src/main/res/values-es-rES/strings.xml | 23 +- app/src/main/res/values-fi-rFI/strings.xml | 23 +- app/src/main/res/values-fr-rFR/strings.xml | 23 +- app/src/main/res/values-hu-rHU/strings.xml | 23 +- app/src/main/res/values-it/strings.xml | 23 +- app/src/main/res/values-ja-rJP/strings.xml | 23 +- app/src/main/res/values-kk-rKZ/strings.xml | 23 +- app/src/main/res/values-or-rIN/strings.xml | 23 +- app/src/main/res/values-pl-rPL/strings.xml | 23 +- app/src/main/res/values-pt-rBR/strings.xml | 23 +- app/src/main/res/values-pt-rPT/strings.xml | 23 +- app/src/main/res/values-ru-rRU/strings.xml | 23 +- app/src/main/res/values-sr/strings.xml | 23 +- app/src/main/res/values-tr-rTR/strings.xml | 23 +- app/src/main/res/values-uk-rUA/strings.xml | 23 +- app/src/main/res/values/strings.xml | 23 +- 40 files changed, 1784 insertions(+), 25 deletions(-) create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashData.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashEntry.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashHandler.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashRepository.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashEmailConfirmationSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerSheet.kt create mode 100644 app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerViewModel.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 8ae4c0c93..8c7f858fa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -13,12 +13,15 @@ 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 @@ -30,6 +33,7 @@ import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.koin.dsl.module import org.koin.ksp.generated.module class DankChatApplication : @@ -42,12 +46,23 @@ class DankChatApplication : 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() + val crashHandler = CrashHandler( + context = this, + isCrashReportingEnabled = { developerSettingsDataStore.current().debugMode }, + ) + crashHandler.install() startKoin { androidContext(this@DankChatApplication) - modules(DankChatModule().module) + modules( + DankChatModule().module, + module { + single { CrashRepository(crashHandler.dataStore) } + }, + ) } connectionCoordinator.initialize() 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..b32dd7f87 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashRepository.kt @@ -0,0 +1,100 @@ +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 + +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/log/LogRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogRepository.kt index 6905a4a0a..2cc2cd415 100644 --- 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 @@ -30,6 +30,8 @@ class LogRepository( 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/preferences/developer/DeveloperSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt index 4df13d076..082570693 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -1,6 +1,7 @@ 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 @@ -25,11 +26,13 @@ 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 @@ -37,6 +40,7 @@ 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 @@ -66,9 +70,11 @@ 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 @@ -91,6 +97,7 @@ import org.koin.compose.viewmodel.koinViewModel fun DeveloperSettingsScreen( onBack: () -> Unit, onOpenLogViewer: (fileName: String) -> Unit = {}, + onOpenCrashViewer: (crashId: Long) -> Unit = {}, ) { val viewModel = koinViewModel() val settings = viewModel.settings.collectAsStateWithLifecycle().value @@ -128,6 +135,7 @@ fun DeveloperSettingsScreen( onInteraction = { viewModel.onInteraction(it) }, onBack = onBack, onOpenLogViewer = onOpenLogViewer, + onOpenCrashViewer = onOpenCrashViewer, ) } @@ -139,6 +147,7 @@ private fun DeveloperSettingsContent( onInteraction: (DeveloperSettingsInteraction) -> Unit, onBack: () -> Unit, onOpenLogViewer: (fileName: String) -> Unit, + onOpenCrashViewer: (crashId: Long) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -225,6 +234,123 @@ private fun DeveloperSettingsContent( 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), @@ -561,3 +687,9 @@ private fun MissingScopesDialog( onDismiss = onDismissRequest, ) } + +private sealed interface CrashDeleteConfirmation { + data object None : CrashDeleteConfirmation + + data object All : CrashDeleteConfirmation +} 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..645e89505 --- /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 android.net.Uri +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 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, Uri.parse("mailto:")) + } + 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..02397b5eb --- /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.android.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/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt index 79037b59f..f2bad28ef 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -64,6 +64,7 @@ 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 @@ -435,6 +436,17 @@ class MainActivity : ComponentActivity() { 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( 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 index c80020a94..047586ea8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt @@ -63,3 +63,8 @@ object Onboarding 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/dialog/DialogState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogState.kt index f2c0d54c1..1002529d7 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -20,4 +21,5 @@ data class DialogState( 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 index 3711c17d8..711128426 100644 --- 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 @@ -1,8 +1,10 @@ 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 @@ -11,15 +13,29 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.koin.android.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) } @@ -124,6 +140,16 @@ class DialogStateViewModel( 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/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt index 4c8f2d16b..87c0175b6 100644 --- 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 @@ -1,40 +1,68 @@ package com.flxrs.dankchat.ui.main.dialog import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +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.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.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.message.MessageOptionsParams @@ -80,6 +108,7 @@ fun MainScreenDialogs( onOpenLogViewer: () -> Unit = {}, ) { val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() val chatInputViewModel: ChatInputViewModel = koinViewModel() @@ -248,6 +277,45 @@ fun MainScreenDialogs( } } + 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( @@ -506,3 +574,264 @@ private fun UserPopupDialogContainer( }, ) } + +@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, Uri.parse("mailto:")) + } + 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/utils/compose/ConfirmationBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt index 3e121380a..7506fe792 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt @@ -7,6 +7,8 @@ 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 @@ -28,6 +30,7 @@ fun ConfirmationBottomSheet( onDismiss: () -> Unit, message: String? = null, confirmText: String = stringResource(R.string.dialog_ok), + confirmColors: ButtonColors? = null, dismissText: String = stringResource(R.string.dialog_cancel), ) { ModalBottomSheet( @@ -78,6 +81,7 @@ fun ConfirmationBottomSheet( Button( onClick = onConfirm, modifier = Modifier.weight(1f), + colors = confirmColors ?: ButtonDefaults.buttonColors(), ) { Text(confirmText) } 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 e29e207f8..1a94bfb75 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 @@ -461,7 +461,7 @@ 頻道資訊 開發者選項 除錯模式 - 在輸入欄中顯示除錯分析操作 + 在輸入欄中顯示除錯分析操作並在本機收集當機報告 時間戳記格式 啟用文字朗讀 朗讀目前選取頻道的訊息 @@ -860,5 +860,26 @@ 已選取 %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 7594b99c3..7d492f7fe 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -327,7 +327,7 @@ Дадзеныя канала Налады для распрацоўшчыкаў Рэжым адладкі - Паказваць дзеянне адладкавай аналітыкі ў панэлі ўводу + Паказваць дзеянне адладкавай аналітыкі ў панэлі ўводу і збіраць справаздачы пра збоі лакальна Фармат часавых пазнак Уключыць сінтэзатар гаворкі Зачытвае паведамленні актыўнага канала @@ -878,5 +878,26 @@ Выбрана: %1$d Капіраваць выбраныя логі Ачысціць выбар + Выяўлены збой + Праграма аварыйна завяршылася падчас апошняга сеансу. + Паток: %1$s + Капіраваць + Справаздача ў чат + Далучаецца да #flex3rs і рыхтуе зводку збою для адпраўкі + Справаздача па эл. пошце + Адправіць падрабязную справаздачу пра збой па электроннай пошце + Адправіць справаздачу пра збой па электроннай пошце + Наступныя дадзеныя будуць уключаны ў справаздачу: + Стэк выклікаў + Уключыць бягучы файл журнала + Справаздачы пра збоі + Прагляд апошніх справаздач пра збоі + Справаздачы пра збоі не знойдзены + Справаздача пра збой + Падзяліцца справаздачай пра збой + Выдаліць + Выдаліць гэту справаздачу пра збой? + Ачысціць усё + Выдаліць усе справаздачы пра збоі? Пракруціць уніз diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 2af31bd12..8f4923225 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -331,7 +331,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Dades del canal Opcions de desenvolupador Mode de depuració - Mostra l\'acció d\'analítiques de depuració a la barra d\'entrada + 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 @@ -903,5 +903,26 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader %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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 66532ad3b..922359b60 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -333,7 +333,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í - Zobrazit akci ladící analytiky ve vstupním panelu + 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 @@ -878,5 +878,26 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade %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ů diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 6e20122dc..fb1e5edb0 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -324,7 +324,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Kanalinformationen Entwickleroptionen Debug-Modus - Debug-Analyse-Aktion in der Eingabeleiste anzeigen + Debug-Analyse-Aktion in der Eingabeleiste anzeigen und Absturzberichte lokal sammeln Formatierung des Zeitstempels TTS aktivieren Liest Nachrichten des aktiven Kanals vor @@ -877,5 +877,26 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ %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 diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 960e15742..4879b5f22 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -287,7 +287,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Channel data Developer options Debug mode - Show debug analytics action in input bar + Show debug analytics action in input bar and collect crash reports locally Timestamp format Enable TTS Reads out messages of the active channel @@ -856,5 +856,26 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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 diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index e1ebc9e43..f52202589 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -287,7 +287,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Channel data Developer options Debug mode - Show debug analytics action in input bar + Show debug analytics action in input bar and collect crash reports locally Timestamp format Enable TTS Reads out messages of the active channel @@ -856,5 +856,26 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/%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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 512a5f49a..5b1dbb75b 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -317,7 +317,7 @@ Channel data Developer options Debug mode - Show debug analytics action in input bar + Show debug analytics action in input bar and collect crash reports locally Timestamp format Enable TTS Reads out messages of the active channel @@ -871,5 +871,26 @@ %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 diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index cd0c1e8c0..9b642a658 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -328,7 +328,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Datos del canal Opciones de desarrollador Modo depuración - Mostrar acción de análisis de depuración en la barra de entrada + 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 @@ -887,5 +887,26 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/%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 diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index da29b80f8..f1a35a7d0 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -323,7 +323,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Kanavatiedot Kehittäjävaihtoehdot Virheenkorjaustila - Näytä virheenkorjausanalytiikkatoiminto syöttöpalkissa + Näytä virheenkorjausanalytiikkatoiminto syöttöpalkissa ja kerää kaatumisraportit paikallisesti Aikaleiman muoto Ota TTS käyttöön Lukee ääneen aktiivisen kanavan viestit @@ -878,5 +878,26 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana %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 diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b604ba3cd..cb35d6bb9 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -327,7 +327,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 - Afficher l\'action d\'analyse de débogage dans la barre de saisie + 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 @@ -871,5 +871,26 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les %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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index c063575cd..430880228 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -316,7 +316,7 @@ Csatorna adatok Fejlesztői beállítások Hibakeresési mód - Hibakeresési elemzési művelet megjelenítése a beviteli sávban + 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 @@ -854,5 +854,26 @@ %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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 56cba10e1..33e0d9b13 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -321,7 +321,7 @@ Dati del canale Opzioni per sviluppatori Modalità di debug - Mostra l\'azione di analisi di debug nella barra di input + 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 @@ -854,5 +854,26 @@ %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 diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 32824328e..43b9c42f8 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -310,7 +310,7 @@ チャンネルデータ 開発者オプション デバッグモード - 入力バーにデバッグ分析アクションを表示 + 入力バーにデバッグ分析アクションを表示し、クラッシュレポートをローカルに収集 タイムスタンプ形式 TTSの有効化 アクティブなチャンネルのメッセージを読み込む @@ -834,5 +834,26 @@ %1$d 件選択中 選択したログをコピー 選択を解除 + クラッシュを検出 + 前回のセッション中にアプリがクラッシュしました。 + スレッド: %1$s + コピー + チャットレポート + #flex3rsに参加し、送信用のクラッシュ概要を準備します + メールレポート + 詳細なクラッシュレポートをメールで送信 + クラッシュレポートをメールで送信 + 以下のデータがレポートに含まれます: + スタックトレース + 現在のログファイルを含める + クラッシュレポート + 最近のクラッシュレポートを表示 + クラッシュレポートが見つかりません + クラッシュレポート + クラッシュレポートを共有 + 削除 + このクラッシュレポートを削除しますか? + すべて消去 + すべてのクラッシュレポートを削除しますか? 一番下にスクロール diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index 53fa87ea3..7d0ced9aa 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -459,7 +459,7 @@ Арна деректері Әзірлеуші параметрлері Дебью режімі - Енгізу жолағында жөндеу аналитикасы әрекетін көрсету + Енгізу жолағында жөндеу аналитикасы әрекетін көрсету және апат есептерін жергілікті түрде жинау Таймстам пішімі TTS қосу Белсенді арнаның хабарларын оқиды @@ -859,5 +859,26 @@ %1$d таңдалды Таңдалған журналдарды көшіру Таңдауды тазалау + Апат анықталды + Қолданба соңғы сеанс кезінде апатқа ұшырады. + Ағын: %1$s + Көшіру + Чат есебі + #flex3rs арнасына қосылып, жіберу үшін апат жиынтығын дайындайды + Электрондық пошта есебі + Толық апат есебін электрондық пошта арқылы жіберу + Апат есебін электрондық пошта арқылы жіберу + Келесі деректер есепке қосылады: + Стек ізі + Ағымдағы журнал файлын қосу + Апат есептері + Соңғы апат есептерін көру + Апат есептері табылмады + Апат есебі + Апат есебін бөлісу + Жою + Бұл апат есебін жою керек пе? + Барлығын тазалау + Барлық апат есептерін жою керек пе? Төменге айналдыру diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 111c71a2f..70145e75f 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -459,7 +459,7 @@ ଚ୍ୟାନେଲ ତଥ୍ୟ | ଡେଵେଲପର୍ ଵିକଳ୍ପ କିବଗ୍ ମୋଡ୍ | - ଇନପୁଟ ବାରରେ ଡିବଗ ଆନାଲିଟିକ୍ସ କାର୍ଯ୍ୟ ଦେଖାନ୍ତୁ + ଇନପୁଟ ବାରରେ ଡିବଗ ଆନାଲିଟିକ୍ସ କାର୍ଯ୍ୟ ଦେଖାନ୍ତୁ ଏବଂ ସ୍ଥାନୀୟ ଭାବରେ କ୍ର୍ୟାସ ରିପୋର୍ଟ ସଂଗ୍ରହ କରନ୍ତୁ ଟାଇମଷ୍ଟ୍ୟାମ୍ପ ଫର୍ମାଟ୍ | TTS ସକ୍ଷମ କରନ୍ତୁ | ସକ୍ରିୟ ଚ୍ୟାନେଲର ବାର୍ତ୍ତା ପ Read ଼େ | @@ -859,5 +859,26 @@ %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 0e87be340..a3f3e0d91 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -330,7 +330,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Dane kanału Opcje deweloperskie Tryb debugowania - Pokaż akcję analityki debugowania na pasku wprowadzania + 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 @@ -896,5 +896,26 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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ół diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7bf9b8776..33887e5f4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -322,7 +322,7 @@ Dados do canal Opções de desenvolvedor Modo de depuração - Mostrar ação de análise de depuração na barra de entrada + 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 @@ -866,5 +866,26 @@ %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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d923597fa..404c5b378 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -322,7 +322,7 @@ Dados do canal Opções de programador Modo de depuração - Mostrar ação de análise de depuração na barra de introdução + 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 @@ -856,5 +856,26 @@ %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 diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 95fe0c110..c40555fdf 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -332,7 +332,7 @@ Данные канала Настройки разработчика Режим отладки - Показывать действие отладочной аналитики в панели ввода + Показывать действие отладочной аналитики в панели ввода и собирать отчёты о сбоях локально Формат временных меток Включить синтезатор речи Зачитывает сообщения активного канала @@ -883,5 +883,26 @@ Выбрано: %1$d Копировать выбранные журналы Снять выделение + Обнаружен сбой + Приложение аварийно завершилось во время последнего сеанса. + Поток: %1$s + Копировать + Отчёт в чат + Присоединяется к #flex3rs и готовит сводку сбоя для отправки + Отчёт по эл. почте + Отправить подробный отчёт о сбое по электронной почте + Отправить отчёт о сбое по электронной почте + Следующие данные будут включены в отчёт: + Стек вызовов + Включить текущий файл журнала + Отчёты о сбоях + Просмотр последних отчётов о сбоях + Отчёты о сбоях не найдены + Отчёт о сбое + Поделиться отчётом о сбое + Удалить + Удалить этот отчёт о сбое? + Очистить всё + Удалить все отчёты о сбоях? Прокрутить вниз diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index d69d6d256..afff6a913 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -421,7 +421,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Podaci o kanalu Programerska podešavanja Debug mod - Прикажи акцију аналитике за отклањање грешака у траци за унос + Прикажи акцију аналитике за отклањање грешака у траци за унос и локално прикупљај извештаје о падовима Format vremenskih markica Omogući čitanje (tekst u govor) Čita poruke aktivnog kanala @@ -907,5 +907,26 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/%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 7b07603f1..611dc2f03 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -322,7 +322,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 - Giriş çubuğunda hata ayıklama analitik eylemini göster + 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 @@ -875,5 +875,26 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ %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 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index c40407821..c1a46935f 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -334,7 +334,7 @@ Дані каналу Налаштування розробника Режим відлагодження - Показувати дію аналітики налагодження в панелі введення + Показувати дію аналітики налагодження в панелі введення та збирати звіти про збої локально Формат часових міток Увімкнути синтезатор мовлення Зачитує повідомлення в активному чаті @@ -880,5 +880,26 @@ Вибрано: %1$d Копіювати вибрані журнали Скасувати вибір + Виявлено збій + Додаток аварійно завершився під час останнього сеансу. + Потік: %1$s + Копіювати + Звіт у чат + Приєднується до #flex3rs і готує зведення збою для відправки + Звіт електронною поштою + Надіслати детальний звіт про збій електронною поштою + Надіслати звіт про збій електронною поштою + Наступні дані будуть включені у звіт: + Стек викликів + Включити поточний файл журналу + Звіти про збої + Переглянути останні звіти про збої + Звітів про збої не знайдено + Звіт про збій + Поділитися звітом про збій + Видалити + Видалити цей звіт про збій? + Очистити все + Видалити всі звіти про збої? Прокрутити донизу diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 981f31f47..b5ef52f67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -517,7 +517,7 @@ Log viewer View application logs Debug mode - Show debug analytics action in input bar + Show debug analytics action in input bar and collect crash reports locally timestamp_format_key Timestamp format tts_key @@ -733,6 +733,27 @@ %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 From af230b9ce5838c6e65b87ec3c95bf86112453db5 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 12:18:11 +0200 Subject: [PATCH 312/349] refactor(ui): Unify sheet sub-view animations and use error color for destructive confirmations --- .../tools/upload/ImageUploaderScreen.kt | 2 ++ .../dankchat/ui/chat/user/UserPopupDialog.kt | 19 +++++++++---------- .../ui/main/dialog/ConfirmationDialog.kt | 3 +++ .../ui/main/dialog/MainScreenDialogs.kt | 4 ++++ .../ui/main/dialog/ManageChannelsDialog.kt | 13 ++++++------- .../ui/main/dialog/MessageOptionsDialog.kt | 13 ++++++------- .../ui/main/dialog/ModActionsDialog.kt | 15 +++++++-------- 7 files changed, 37 insertions(+), 32 deletions(-) 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 d073e5ef3..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 @@ -21,6 +21,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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 @@ -231,6 +232,7 @@ private fun ImageUploaderScreen( 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() 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 index e8e1df588..5b7e13199 100644 --- 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 @@ -27,6 +27,7 @@ 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 @@ -86,12 +87,6 @@ fun UserPopupDialog( ) { AnimatedContent( targetState = showBlockConfirmation, - transitionSpec = { - when { - targetState -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() - } - }, label = "UserPopupContent", ) { isBlockConfirmation -> when { @@ -124,10 +119,14 @@ fun UserPopupDialog( Text(stringResource(R.string.dialog_cancel)) } Spacer(modifier = Modifier.width(12.dp)) - Button(onClick = { - onBlockUser() - showBlockConfirmation = false - }, modifier = Modifier.weight(1f)) { + 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)) } } 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 index 09ce5c161..e527eef29 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -11,11 +12,13 @@ fun ConfirmationDialog( 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/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt index 87c0175b6..9f28f3a50 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -148,6 +149,7 @@ fun MainScreenDialogs( 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() @@ -160,6 +162,7 @@ fun MainScreenDialogs( 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() @@ -172,6 +175,7 @@ fun MainScreenDialogs( 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() 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 index 445d7c4e1..ce1954d65 100644 --- 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 @@ -32,6 +32,7 @@ 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 @@ -115,12 +116,6 @@ fun ManageChannelsDialog( ) { AnimatedContent( targetState = channelToDelete, - transitionSpec = { - when { - targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() - } - }, label = "ManageChannelsContent", ) { deleteTarget -> when (deleteTarget) { @@ -401,7 +396,11 @@ private fun DeleteChannelConfirmation( Text(stringResource(R.string.dialog_cancel)) } Spacer(modifier = Modifier.width(12.dp)) - Button(onClick = onConfirm, modifier = Modifier.weight(1f)) { + 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 index c90ee89f0..c6c67b1ad 100644 --- 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 @@ -26,6 +26,7 @@ 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 @@ -91,12 +92,6 @@ fun MessageOptionsDialog( ) { AnimatedContent( targetState = subView, - transitionSpec = { - when { - targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() - } - }, label = "MessageOptionsContent", ) { currentView -> when (currentView) { @@ -368,7 +363,11 @@ private fun ConfirmationSubView( Text(stringResource(R.string.dialog_cancel)) } Spacer(modifier = Modifier.width(12.dp)) - Button(onClick = onConfirm, modifier = Modifier.weight(1f)) { + 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 index 8cfaaca1c..44c01d872 100644 --- 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 @@ -26,6 +26,7 @@ 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 @@ -129,12 +130,6 @@ fun ModActionsDialog( ) { AnimatedContent( targetState = subView, - transitionSpec = { - when { - targetState != null -> slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() - else -> slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() - } - }, label = "ModActionsContent", ) { currentView -> when (currentView) { @@ -702,8 +697,12 @@ private fun ClearChatConfirmSubView( 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)) + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.clear_chat)) } } } From 12846ecb671c9336a52b01f0dad8bb8541018e43 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 13:14:48 +0200 Subject: [PATCH 313/349] fix(i18n): Add missing translations across all maintained locales --- .../main/res/values-b+zh+Hant+TW/strings.xml | 4 +++ app/src/main/res/values-be-rBY/strings.xml | 19 ++++++++++++ app/src/main/res/values-ca/strings.xml | 4 +++ app/src/main/res/values-cs/strings.xml | 25 ++++++++++++++++ app/src/main/res/values-de-rDE/strings.xml | 4 +++ app/src/main/res/values-en-rAU/strings.xml | 12 ++++++++ app/src/main/res/values-en-rGB/strings.xml | 12 ++++++++ app/src/main/res/values-en/strings.xml | 4 +++ app/src/main/res/values-es-rES/strings.xml | 5 ++++ app/src/main/res/values-fi-rFI/strings.xml | 4 +++ app/src/main/res/values-fr-rFR/strings.xml | 19 ++++++++++++ app/src/main/res/values-hu-rHU/strings.xml | 19 ++++++++++++ app/src/main/res/values-it/strings.xml | 29 +++++++++++++++++++ app/src/main/res/values-ja-rJP/strings.xml | 28 ++++++++++++++++++ app/src/main/res/values-kk-rKZ/strings.xml | 4 +++ app/src/main/res/values-or-rIN/strings.xml | 4 +++ app/src/main/res/values-pl-rPL/strings.xml | 5 ++++ app/src/main/res/values-pt-rBR/strings.xml | 19 ++++++++++++ app/src/main/res/values-pt-rPT/strings.xml | 27 +++++++++++++++++ app/src/main/res/values-ru-rRU/strings.xml | 19 ++++++++++++ app/src/main/res/values-sr/strings.xml | 4 +++ app/src/main/res/values-tr-rTR/strings.xml | 5 ++++ app/src/main/res/values-uk-rUA/strings.xml | 29 +++++++++++++++++++ 23 files changed, 304 insertions(+) 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 1a94bfb75..9b41f26db 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 @@ -108,6 +108,7 @@ 貼上 頻道名稱 + 頻道已經加入 退格鍵 常用 訂閱 @@ -279,11 +280,14 @@ 全螢幕 隱藏輸入框 設定動作 + 偵錯 僅限表情符號 僅限訂閱者 低速模式 + 慢速模式 (%1$s) 獨特聊天 (R9K) 僅限追隨者 + 僅限追隨者 (%1$s) 自訂 任何人 %1$d秒 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 7d492f7fe..33ec6e1a0 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -117,6 +117,7 @@ %1$s %2$s %3$s Уставіць Назва канала + Канал ужо дададзены Нядаўнія Смайлы падпіскі Смайлы канала @@ -652,8 +653,10 @@ Толькі эмоуты Толькі падпісчыкі Павольны рэжым + Павольны рэжым (%1$s) Унікальны чат (R9K) Толькі фалаверы + Толькі фалаверы (%1$s) Іншае Любы %1$ds @@ -706,6 +709,7 @@ На ўвесь экран Схаваць увод Наладзіць дзеянні + Адладка Максімум %1$d дзеянне Максімум %1$d дзеянні @@ -900,4 +904,19 @@ Ачысціць усё Выдаліць усе справаздачы пра збоі? Пракруціць уніз + Значок + Адміністратар + Стрымер + Заснавальнік + Галоўны мадэратар + Мадэратар + Супрацоўнік + Падпісчык + Пацверджаны + VIP + Значкі + Стварайце апавяшчэнні і вылучайце паведамленні карыстальнікаў на аснове значкоў. + Абраць колер + Абраць карыстальніцкі колер вылучэння + Па змаўчанні diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 8f4923225..a8342b0a9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -114,6 +114,7 @@ %1$s %2$s %3$s Enganxa Nom del canal + El canal ja està afegit Recent Subscriptors Canal @@ -586,8 +587,10 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader 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 @@ -639,6 +642,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Pantalla completa Amaga l\'entrada Configura accions + Depuració Màxim %1$d acció Màxim %1$d accions diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 922359b60..9a7861712 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -116,6 +116,7 @@ %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 @@ -652,8 +653,10 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 @@ -706,6 +709,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Celá obrazovka Skrýt vstup Konfigurovat akce + Ladění Maximálně %1$d akce Maximálně %1$d akce @@ -900,4 +904,25 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade 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 + diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index fb1e5edb0..da3246744 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -107,6 +107,7 @@ %1$s %2$s %3$s Einfügen Kanalname + Kanal ist bereits hinzugefügt Zuletzt Abos Kanal @@ -653,8 +654,10 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Nur Emotes Nur Abonnenten Langsamer Modus + Langsamer Modus (%1$s) Einzigartiger Chat (R9K) Nur Follower + Nur Follower (%1$s) Benutzerdefiniert Alle %1$ds @@ -707,6 +710,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Vollbild Eingabe ausblenden Aktionen konfigurieren + Debug Maximal %1$d Aktion Maximal %1$d Aktionen diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 4879b5f22..22f6dd904 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -103,6 +103,7 @@ %1$s %2$s %3$s Paste Channel name + Channel is already added Recent Subs Channel @@ -461,8 +462,10 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Emote only Subscriber only Slow mode + Slow mode (%1$s) Unique chat (R9K) Follower only + Follower only (%1$s) Custom Any %1$ds @@ -509,6 +512,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Hide input Configure actions + Debug Maximum of %1$d action Maximum of %1$d actions @@ -878,4 +882,12 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index f52202589..8a0d1791b 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -103,6 +103,7 @@ %1$s %2$s %3$s Paste Channel name + Channel is already added Recent Subs Channel @@ -462,8 +463,10 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Emote only Subscriber only Slow mode + Slow mode (%1$s) Unique chat (R9K) Follower only + Follower only (%1$s) Custom Any %1$ds @@ -510,6 +513,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Fullscreen Hide input Configure actions + Debug Maximum of %1$d action Maximum of %1$d actions @@ -878,4 +882,12 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/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 + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 5b1dbb75b..8ea396ed2 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -107,6 +107,7 @@ %1$s %2$s %3$s Paste Channel name + Channel is already added Recent Subs Channel @@ -647,8 +648,10 @@ Emote only Subscriber only Slow mode + Slow mode (%1$s) Unique chat (R9K) Follower only + Follower only (%1$s) Custom Any %1$ds @@ -701,6 +704,7 @@ Fullscreen Hide input Configure actions + Debug Maximum of %1$d action Maximum of %1$d actions diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 9b642a658..1fdf66077 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -111,6 +111,7 @@ %1$s %2$s %3$s Pegar Nombre del canal + El canal ya está añadido Reciente Suscripciones Canal @@ -662,8 +663,10 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Solo emotes Solo suscriptores Modo lento + Modo lento (%1$s) Chat único (R9K) Solo seguidores + Solo seguidores (%1$s) Personalizado Cualquiera %1$ds @@ -716,6 +719,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Pantalla completa Ocultar entrada Configurar acciones + Depuración Máximo de %1$d acción Máximo de %1$d acciones @@ -909,4 +913,5 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Borrar todo ¿Eliminar todos los informes de crash? Desplazar hacia abajo + Carga completada: %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 f1a35a7d0..cc64ecd61 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -108,6 +108,7 @@ %1$s %2$s %3$s Liitä Kanavan nimi + Kanava on jo lisätty Viimeisimmät Tilaukset Kanava @@ -643,8 +644,10 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana 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 @@ -697,6 +700,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Koko näyttö Piilota syöttö Muokkaa toimintoja + Virheenkorjaus Enintään %1$d toiminto Enintään %1$d toimintoa diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index cb35d6bb9..3d134df90 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -112,6 +112,7 @@ %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 @@ -646,8 +647,10 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 @@ -700,6 +703,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Plein écran Masquer la saisie Configurer les actions + Débogage Maximum de %1$d action Maximum de %1$d actions @@ -893,4 +897,19 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les 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 diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 430880228..b79c6cd34 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -106,6 +106,7 @@ %1$s %2$s %3$s Beillesztés Csatorna neve + A csatorna már hozzá van adva Legutóbbi Feliratkozói Csatorna @@ -630,8 +631,10 @@ 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 @@ -684,6 +687,7 @@ Teljes képernyő Bevitel elrejtése Műveletek beállítása + Hibakeresés Maximum %1$d művelet Maximum %1$d művelet @@ -876,4 +880,19 @@ Ö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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 33e0d9b13..1aa062cd2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -111,6 +111,7 @@ %1$s %2$s %3$s Incolla Nome del canale + Il canale è già stato aggiunto Recente Abbonati Canale @@ -629,8 +630,10 @@ Solo emote Solo abbonati Modalità lenta + Modalità lenta (%1$s) Chat unica (R9K) Solo follower + Solo follower (%1$s) Personalizzato Qualsiasi %1$ds @@ -683,6 +686,7 @@ Schermo intero Nascondi input Configura azioni + Debug Massimo %1$d azione Massimo %1$d azioni @@ -876,4 +880,29 @@ 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 + diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 43b9c42f8..6db4c2b1e 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -100,6 +100,7 @@ %1$s %2$s %3$s 貼り付け チャンネル名 + チャンネルは既に追加されています 新着 サブスク チャンネル @@ -611,8 +612,10 @@ エモートのみ サブスクライバーのみ スローモード + スローモード (%1$s) ユニークチャット (R9K) フォロワーのみ + フォロワーのみ (%1$s) カスタム すべて %1$ds @@ -665,6 +668,7 @@ 全画面 入力欄を非表示 アクションを設定 + デバッグ 最大%1$d個のアクション @@ -856,4 +860,28 @@ すべて消去 すべてのクラッシュレポートを削除しますか? 一番下にスクロール + 戻る + 保存 + バッジ + 管理者 + 配信者 + ファウンダー + リードモデレーター + モデレーター + スタッフ + サブスクライバー + 認証済み + 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 7d0ced9aa..1d4d55373 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -110,6 +110,7 @@ %1$s %2$s %3$s Қою Арна аты + Арна әлдеқашан қосылған Соңғы Кері қарай Қосалқылар @@ -278,11 +279,14 @@ Толық экран Енгізуді жасыру Әрекеттерді баптау + Жөндеу Тек эмоция Тек жазылушы Баяу режим + Баяу режим (%1$s) Бірегей чат (R9K) Тек ізбасар + Тек жазылушылар (%1$s) Реттелмелі Кез келген %1$dс diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 70145e75f..580d0f5e1 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -110,6 +110,7 @@ %1$s %2$s %3$s ଲେପନ କରନ୍ତୁ | ଚ୍ୟାନେଲ ନାମ | + ଚ୍ୟାନେଲ ପୂର୍ବରୁ ଯୋଡ଼ା ହୋଇସାରିଛି ସମ୍ପ୍ରତି ବ୍ୟାକସ୍ପେସ୍ ଗ୍ରାହକଗଣ | @@ -278,11 +279,14 @@ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ କ୍ରିୟାଗୁଡିକ ବିନ୍ୟାସ କରନ୍ତୁ + ଡିବଗ୍ କେବଳ ଇମୋଟ୍ କେବଳ ଗ୍ରାହକ ସ୍ଲୋ ମୋଡ୍ + ମନ୍ଥର ମୋଡ୍ (%1$s) ୟୁନିକ୍ ଚାଟ୍ (R9K) କେବଳ ଅନୁଗାମୀ + କେବଳ ଅନୁସରଣକାରୀ (%1$s) କଷ୍ଟମ ଯେକୌଣସି %1$ds diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a3f3e0d91..bc7a2cf73 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -115,6 +115,7 @@ %1$s %2$s %3$s Wklej Nazwa kanału + Kanał jest już dodany Ostatnie Subskrybcje Kanał @@ -670,8 +671,10 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości 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 @@ -724,6 +727,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Pełny ekran Ukryj pole wpisywania Konfiguruj akcje + Debugowanie Maksymalnie %1$d akcja Maksymalnie %1$d akcje @@ -918,4 +922,5 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Wyczyść wszystko Usunąć wszystkie raporty o awariach? Przewiń na dół + Przesyłanie zakończone: %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 33887e5f4..815872370 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -112,6 +112,7 @@ %1$s %2$s %3$s Colar Nome do canal + O canal já foi adicionado Recente Inscritos Canal @@ -641,8 +642,10 @@ Apenas emotes Apenas assinantes Modo lento + Modo lento (%1$s) Chat único (R9K) Apenas seguidores + Apenas seguidores (%1$s) Personalizado Qualquer %1$ds @@ -695,6 +698,7 @@ Tela cheia Ocultar entrada Configurar ações + Depuração Máximo de %1$d ação Máximo de %1$d ações @@ -888,4 +892,19 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 404c5b378..0fd9c4e80 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -112,6 +112,7 @@ %1$s %2$s %3$s Colar Nome do canal + O canal já foi adicionado Recente Subscritores Canal @@ -631,8 +632,10 @@ Apenas emotes Apenas subscritores Modo lento + Modo lento (%1$s) Chat único (R9K) Apenas seguidores + Apenas seguidores (%1$s) Personalizado Qualquer %1$ds @@ -685,6 +688,7 @@ Ecrã inteiro Ocultar entrada Configurar ações + Depuração Máximo de %1$d ação Máximo de %1$d ações @@ -878,4 +882,27 @@ 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 + diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index c40555fdf..735dc3fc6 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -117,6 +117,7 @@ %1$s %2$s %3$s Вставить Имя канала + Канал уже добавлен Недавние Смайлы подписки Смайлы канала @@ -657,8 +658,10 @@ Только эмоуты Только подписчики Медленный режим + Медленный режим (%1$s) Уникальный чат (R9K) Только фолловеры + Только фолловеры (%1$s) Другое Любой %1$ds @@ -711,6 +714,7 @@ На весь экран Скрыть ввод Настроить действия + Отладка Максимум %1$d действие Максимум %1$d действия @@ -905,4 +909,19 @@ Очистить всё Удалить все отчёты о сбоях? Прокрутить вниз + Значок + Администратор + Стример + Основатель + Главный модератор + Модератор + Сотрудник + Подписчик + Подтверждённый + VIP + Значки + Создавайте уведомления и выделяйте сообщения пользователей на основе значков. + Выбрать цвет + Выбрать пользовательский цвет выделения + По умолчанию diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index afff6a913..3fd4f8ace 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -115,6 +115,7 @@ %1$s %2$s %3$s Paste Naziv kanala + Канал је већ додат Недавно Pretplatnici Kanal @@ -651,8 +652,10 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Само емотикони Само претплатници Спори режим + Спори режим (%1$s) Јединствени чат (R9K) Само пратиоци + Само пратиоци (%1$s) Прилагођено Било који %1$ds @@ -704,6 +707,7 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Цео екран Сакриј унос Подеси акције + Отклањање грешака Максимално %1$d акција Максимално %1$d акције diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 611dc2f03..0dbb88cac 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -105,6 +105,7 @@ %1$s %2$s %3$s Yapıştır Kanal adı + Kanal zaten eklenmiş Son kullanılanlar Abonelikler Kanal @@ -651,8 +652,10 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ 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 @@ -705,6 +708,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Tam ekran Girişi gizle Eylemleri yapılandır + Hata ayıklama En fazla %1$d eylem En fazla %1$d eylem @@ -897,4 +901,5 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Tümünü temizle Tüm çökme raporları silinsin mi? En alta kaydır + Yükleme tamamlandı: %1$s diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index c1a46935f..252c0edd4 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -117,6 +117,7 @@ %1$s %2$s %3$s Вставити Ім\'я каналу + Канал вже додано Останні Підписки Смайли каналу @@ -654,8 +655,10 @@ Лише емоути Лише підписники Повільний режим + Повільний режим (%1$s) Унікальний чат (R9K) Лише фоловери + Лише фоловери (%1$s) Інше Будь-який %1$ds @@ -708,6 +711,7 @@ На весь екран Сховати введення Налаштувати дії + Налагодження Максимум %1$d дія Максимум %1$d дії @@ -902,4 +906,29 @@ Очистити все Видалити всі звіти про збої? Прокрутити донизу + Значок + Адміністратор + Стрімер + Засновник + Головний модератор + Модератор + Співробітник + Підписник + Підтверджений + 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 + From 9bb53c4de3093390f4b297978d6a521da1d4fbd4 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 13:22:04 +0200 Subject: [PATCH 314/349] chore: Bump version to 4.0.15 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b5f8ab04..4640cfbd0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.flxrs.dankchat" minSdk = 30 targetSdk = 35 - versionCode = 40014 - versionName = "4.0.14" + versionCode = 40015 + versionName = "4.0.15" } androidResources { generateLocaleConfig = true } From 004869dec80a404fa0e973cea41647666b487cc0 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 14:26:38 +0200 Subject: [PATCH 315/349] perf(pager): Pre-compose adjacent channel pages for smoother swiping --- .../kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt | 1 + 1 file changed, 1 insertion(+) 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 index 543fcfd46..0c3dea2cd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -107,6 +107,7 @@ internal fun MainScreenPagerContent( state = composePagerState, modifier = Modifier.fillMaxSize().edgeGestureGuard(), userScrollEnabled = swipeNavigation, + beyondViewportPageCount = 1, key = { index -> pagerState.channels.getOrNull(index)?.value ?: index }, ) { page -> if (page in pagerState.channels.indices) { From b060778284057d24c879de25abc90582b4a75b0e Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 14:59:06 +0200 Subject: [PATCH 316/349] perf(chat): Skip SubcomposeLayout when all inline content dimensions are cached --- .../messages/common/MessageTextRenderer.kt | 15 +- .../common/TextWithMeasuredInlineContent.kt | 218 ++++++++++++------ 2 files changed, 154 insertions(+), 79 deletions(-) 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 index 76943d78e..7e0c00efd 100644 --- 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 @@ -86,22 +86,19 @@ fun MessageTextWithInlineContent( val baseHeightPx = with(density) { baseHeight.toPx().toInt() } emotes.forEach { emote -> val id = "EMOTE_${emote.code}" - when { + val dims = when { emote.urls.size == 1 -> { - val dims = emoteCoordinator.dimensionCache.get(emote.urls.first()) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } + emoteCoordinator.dimensionCache.get(emote.urls.first()) } else -> { val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" - val dims = emoteCoordinator.dimensionCache.get(cacheKey) - if (dims != null) { - put(id, EmoteDimensions(id, dims.first, dims.second)) - } + emoteCoordinator.dimensionCache.get(cacheKey) } } + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } } }.toImmutableMap() } 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 index 99635559e..bb168ccb6 100644 --- 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 @@ -3,7 +3,6 @@ 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.layout.Box import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable @@ -20,6 +19,7 @@ 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 @@ -44,15 +44,115 @@ fun TextWithMeasuredInlineContent( 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 -> - // Phase 1: Measure inline content to get actual dimensions - // Skip measurement for IDs with pre-known dimensions (from cache) val measuredDimensions = mutableMapOf() - - // Add all pre-known dimensions first measuredDimensions.putAll(knownDimensions) // Resolve spacer inline content from annotated string annotations @@ -70,7 +170,6 @@ fun TextWithMeasuredInlineContent( if (id !in knownDimensions) { val measurables = subcompose("measure_$id", provider) if (measurables.isNotEmpty()) { - // Measure with unbounded constraints to get natural size val placeable = measurables.first().measure( Constraints( @@ -88,75 +187,17 @@ fun TextWithMeasuredInlineContent( } } - // Phase 2: Create InlineTextContent with measured/known dimensions - val inlineContent = - measuredDimensions.mapValues { (id, dimensions) -> - InlineTextContent( - placeholder = - Placeholder( - width = with(density) { dimensions.widthPx.toDp() }.value.sp, - height = with(density) { dimensions.heightPx.toDp() }.value.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, - ), - ) { - // Render the actual content (re-compose with same provider) - inlineContentProviders[id]?.invoke() - } - } - - // Phase 3: Compose the text with correct inline content + val inlineContent = buildInlineContentMapFromDimensions(measuredDimensions, density, inlineContentProviders) val textMeasurables = subcompose("text") { - BasicText( + ClickableInlineText( text = text, style = style, inlineContent = inlineContent, - modifier = - Modifier.pointerInput(text, interactionSource) { - detectTapGestures( - onPress = { offset -> - // Emit press interaction for ripple effect - 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) { - val position = layoutResult.getOffsetForPosition(offset) - onTextClick?.invoke(position) - } - } - }, - 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 - }, + onTextClick = onTextClick, + onTextLongClick = onTextLongClick, + interactionSource = interactionSource, ) } @@ -164,7 +205,6 @@ fun TextWithMeasuredInlineContent( return@SubcomposeLayout layout(0, 0) {} } - // Phase 4: Measure and layout the text val textPlaceable = textMeasurables.first().measure(constraints) layout(textPlaceable.width, textPlaceable.height) { @@ -173,4 +213,42 @@ fun TextWithMeasuredInlineContent( } } +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" From c8dd346570816aad3a5b9eba78d6afdcad38a7ec Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 15:18:50 +0200 Subject: [PATCH 317/349] fix(menu): Size overflow menu width to fit main menu items, allow sub-menu text wrapping --- .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) 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 index 9980f948d..7c455d11f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -61,9 +61,11 @@ 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.LocalContext 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 @@ -116,6 +118,8 @@ fun InlineOverflowMenu( } val density = LocalDensity.current + val menuWidth = rememberMainMenuWidth(isLoggedIn) + val screenHeight = with(density) { LocalWindowInfo.current.containerSize.height @@ -140,7 +144,7 @@ fun InlineOverflowMenu( state = scrollAreaState, modifier = Modifier - .width(200.dp) + .width(menuWidth) .heightIn(max = maxHeight), ) { AnimatedContent( @@ -215,6 +219,7 @@ private fun InlineMenuItem( icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, + maxLines: Int = 1, hasSubMenu: Boolean = false, ) { Row( @@ -236,7 +241,7 @@ private fun InlineMenuItem( text = text, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) @@ -395,6 +400,7 @@ private fun ColumnScope.UploadMenuContent( onDismiss() }, modifier = modifier, + maxLines = 2, ) InlineMenuItem( text = stringResource(R.string.record_video), @@ -403,6 +409,7 @@ private fun ColumnScope.UploadMenuContent( onAction(ToolbarAction.CaptureVideo) onDismiss() }, + maxLines = 2, ) InlineMenuItem( text = stringResource(R.string.choose_media), @@ -411,6 +418,7 @@ private fun ColumnScope.UploadMenuContent( onAction(ToolbarAction.ChooseMedia) onDismiss() }, + maxLines = 2, ) } @@ -431,6 +439,7 @@ private fun ColumnScope.ChannelMenuContent( onDismiss() }, modifier = modifier, + maxLines = 2, ) InlineMenuItem( text = stringResource(R.string.report_channel), @@ -439,6 +448,7 @@ private fun ColumnScope.ChannelMenuContent( onAction(ToolbarAction.ReportChannel) onDismiss() }, + maxLines = 2, ) if (isLoggedIn) { InlineMenuItem( @@ -448,6 +458,39 @@ private fun ColumnScope.ChannelMenuContent( 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 context = LocalContext.current + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val textStyle = MaterialTheme.typography.bodyLarge + + return remember(isLoggedIn, density, textStyle) { + val mainItemTexts = buildList { + if (isLoggedIn) { + add(context.getString(R.string.relogin)) + add(context.getString(R.string.logout)) + } else { + add(context.getString(R.string.login)) + } + add(context.getString(R.string.manage_channels)) + add(context.getString(R.string.remove_channel)) + add(context.getString(R.string.reload_emotes)) + add(context.getString(R.string.reconnect)) + add(context.getString(R.string.upload_media)) + add(context.getString(R.string.channel)) + add(context.getString(R.string.settings)) + } + 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) + } +} From 1caf7002a29e367dac09b285ace09af466186950 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 6 Apr 2026 16:18:23 +0200 Subject: [PATCH 318/349] fix(theme): Replace material-android-components with framework themes, add splash screen, fix theme flashes --- app/build.gradle.kts | 5 +- .../com/flxrs/dankchat/DankChatApplication.kt | 21 +- .../appearance/AppearanceSettingsScreen.kt | 30 +- .../flxrs/dankchat/ui/main/MainActivity.kt | 616 +++++++++--------- app/src/main/res/values-night/themes.xml | 4 +- .../res/values/ic_launcher_background.xml | 3 +- app/src/main/res/values/themes.xml | 6 +- gradle/libs.versions.toml | 6 +- 8 files changed, 372 insertions(+), 319 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4640cfbd0..f12fea0b3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -178,8 +178,9 @@ dependencies { 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 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 8c7f858fa..90fc66a54 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -30,6 +30,7 @@ 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 @@ -67,9 +68,7 @@ class DankChatApplication : connectionCoordinator.initialize() - scope.launch(dispatchersProvider.immediate) { - setupThemeMode() - } + setupThemeMode() highlightsRepository.runMigrationsIfNeeded() ignoresRepository.runMigrationsIfNeeded() @@ -78,15 +77,13 @@ class DankChatApplication : } } - private suspend fun setupThemeMode() { - val theme = appearanceSettingsDataStore.settings.first().theme - - val nightMode = - when { - theme == Dark -> 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) } 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 index 9aa9fe875..8a22d509c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -1,6 +1,11 @@ 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 @@ -270,6 +275,7 @@ private fun ThemeCategory( 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 @@ -283,10 +289,10 @@ private fun ThemeCategory( values = themeState.values, entries = themeState.entries, selected = themeState.preference, - onChange = { + onChange = { preference -> scope.launch { - onInteraction(Theme(it)) - setDarkMode(it) + onInteraction(Theme(preference)) + setDarkMode(activity, preference) } }, ) @@ -587,7 +593,10 @@ private fun PaletteStyleRow( } } -private fun setDarkMode(themePreference: ThemePreference) { +private fun setDarkMode( + activity: ComponentActivity?, + themePreference: ThemePreference, +) { AppCompatDelegate.setDefaultNightMode( when (themePreference) { ThemePreference.System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM @@ -595,4 +604,17 @@ private fun setDarkMode(themePreference: ThemePreference) { 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/ui/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt index f2bad28ef..6b17cb240 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -5,12 +5,14 @@ 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 @@ -24,11 +26,17 @@ 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 @@ -49,7 +57,9 @@ 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 @@ -91,6 +101,7 @@ 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() @@ -142,7 +153,20 @@ class MainActivity : ComponentActivity() { private var isBound = false override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() + 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) @@ -181,303 +205,305 @@ class MainActivity : ComponentActivity() { val onboardingCompleted = onboardingDataStore.current().hasCompletedOnboarding - 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)) }, + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + NavHost( + navController = navController, + startDestination = if (onboardingCompleted) Main else Onboarding, ) { - 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) - } - }, - ) - } + 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)) - } + 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() }, - ) + 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() }, + ) + } } } } diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 822008e7d..ad49ad795 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,7 +1,9 @@ - -