diff --git a/apps/vbox/client/shared/src/commonMain/composeResources/drawable/svingpt.xml b/apps/vbox/client/shared/src/commonMain/composeResources/drawable/svingpt.xml new file mode 100644 index 0000000..24b2607 --- /dev/null +++ b/apps/vbox/client/shared/src/commonMain/composeResources/drawable/svingpt.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml b/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml index 463d264..7007660 100644 --- a/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml +++ b/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml @@ -68,4 +68,6 @@ Настроить теги Не фильтровать Поиск обновлений + Братан СвинGPT + СвинGPT diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/MovieTextParser.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/MovieTextParser.kt new file mode 100644 index 0000000..559d59b --- /dev/null +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/MovieTextParser.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.vbox.shared.data.chat + +import org.koin.core.annotation.Factory +import ru.shadowsparky.chat.domain.ChatTextParser +import ru.shadowsparky.chat.domain.TextToken + +@Factory +class MovieTextParser : ChatTextParser { + private val regex = Regex("\\[MOVIE\\](.*?)\\[/MOVIE\\]") + + override fun parse(content: String): List { + val tokens = mutableListOf() + var lastIndex = 0 + regex.findAll(content).forEach { matchResult -> + if (matchResult.range.first > lastIndex) { + tokens.add(TextToken.Plain(content.substring(lastIndex, matchResult.range.first))) + } + val movieTitle = matchResult.groupValues[1] + tokens.add(TextToken.Link(text = movieTitle)) + lastIndex = matchResult.range.last + 1 + } + if (lastIndex < content.length) { + tokens.add(TextToken.Plain(content.substring(lastIndex))) + } + return tokens.ifEmpty { listOf(TextToken.Plain(content)) } + } +} diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteChatRepository.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/RemoteChatRepository.kt similarity index 85% rename from apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteChatRepository.kt rename to apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/RemoteChatRepository.kt index 2569da7..84921e3 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteChatRepository.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/chat/RemoteChatRepository.kt @@ -1,4 +1,4 @@ -package ru.shadowsparky.vbox.shared.data +package ru.shadowsparky.vbox.shared.data.chat import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -8,6 +8,8 @@ import org.koin.core.annotation.Factory import ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.Message import ru.shadowsparky.chat.domain.MessagesResponse +import ru.shadowsparky.vbox.shared.data.apply +import ru.shadowsparky.vbox.shared.data.get import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository import ru.shadowsparky.vbox.shared.domain.getServerConfiguration @@ -25,7 +27,7 @@ class RemoteChatRepository( mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString()) } return httpClient.get( - "$DYNAMIC_PREFIX/chat/query", + "${DYNAMIC_PREFIX}/chat/query", args, serverConfigurationRepository.getServerConfiguration() ).content @@ -36,7 +38,7 @@ class RemoteChatRepository( setBody(message) serverConfigurationRepository.getServerConfiguration().apply( this, - "$DYNAMIC_PREFIX/chat/send" + "${DYNAMIC_PREFIX}/chat/send" ) }.body() } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt index 3252037..f99972a 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.seiko.imageloader.LocalImageLoader -import ru.shadowsparky.chat.presentation.ChatScreen import ru.shadowsparky.koin import ru.shadowsparky.ui.components.BackButton import ru.shadowsparky.ui.nav.Nav @@ -64,10 +63,10 @@ private fun NavInternal(root: VBoxRootComponent) { is Child.EditPasswordChild -> EditPasswordScreen(child.component) is Child.LoadingChild -> LoadingScreen(child.component) is Child.OfflineVideoChild -> OfflineVideoScreen(child.component) - is Child.ChatChild -> ChatScreen(child.component) is Child.VideosChild, is Child.SearchChild, is Child.SettingsChild, + is Child.ChatChild, is Child.SavedMovieChild -> { if (child is Child.VideosChild && child.component.search != null) { VideoScreen(child.component) diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt index 6599f45..d6a4917 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt @@ -1,12 +1,14 @@ package ru.shadowsparky.vbox.shared.presentation import androiddev.apps.vbox.client.shared.generated.resources.Res +import androiddev.apps.vbox.client.shared.generated.resources.chat_title import androiddev.apps.vbox.client.shared.generated.resources.home import androiddev.apps.vbox.client.shared.generated.resources.recommendations import androiddev.apps.vbox.client.shared.generated.resources.saved_movies import androiddev.apps.vbox.client.shared.generated.resources.search import androiddev.apps.vbox.client.shared.generated.resources.settings import androiddev.apps.vbox.client.shared.generated.resources.star_unchecked +import androiddev.apps.vbox.client.shared.generated.resources.svingpt import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -31,6 +33,11 @@ fun createNavigationList(): List> { stringResource(Res.string.search), painterResource(Res.drawable.search) ), + NavigationEntry( + Route.Chat(), + stringResource(Res.string.chat_title), + painterResource(Res.drawable.svingpt) + ), NavigationEntry( Route.Settings(), stringResource(Res.string.settings), diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt index fcfbce0..2efceaf 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt @@ -14,6 +14,7 @@ import ru.shadowsparky.ui.LocalBlurState import ru.shadowsparky.ui.applyBlurEffect import ru.shadowsparky.ui.rememberBlurState import ru.shadowsparky.ui.theme.ComponentDefaults +import ru.shadowsparky.vbox.shared.presentation.chat.ChatScreen import ru.shadowsparky.vbox.shared.presentation.list.VideoContent import ru.shadowsparky.vbox.shared.presentation.nav.Child import ru.shadowsparky.vbox.shared.presentation.nav.Route @@ -29,7 +30,7 @@ fun MainScreen(route: Route, child: Child, root: VBoxRootComponent) { child.component, route ) { root.navReplace(it) } - + is Child.ChatChild -> ChatScreen(child.component, route) { root.navReplace(it) } else -> MainScreenInternal(route, child) { root.navReplace(it) } } } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt index cfbe741..f289cbf 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext import org.koin.core.annotation.Factory import org.koin.core.annotation.Named import ru.shadowsparky.chat.domain.ChatRepository +import ru.shadowsparky.chat.domain.ChatTextParser import ru.shadowsparky.chat.presentation.ChatComponent import ru.shadowsparky.ui.nav.ComponentFactory import ru.shadowsparky.ui.nav.RouteNavigator @@ -14,7 +15,8 @@ import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds @Factory @Named(RouteIds.CHAT) class ChatComponentFactory( - private val chatRepository: ChatRepository + private val chatRepository: ChatRepository, + private val textParser: ChatTextParser ) : ComponentFactory { override fun create( route: Route.Chat, @@ -23,7 +25,9 @@ class ChatComponentFactory( ): Child.ChatChild { val comp = ChatComponent( child, - chatRepository + chatRepository, + textParser, + onLinkClicked = { root.nav(Route.Videos(it)) } ) return Child.ChatChild(comp) } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt new file mode 100644 index 0000000..008d7e7 --- /dev/null +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky.vbox.shared.presentation.chat + +import androiddev.apps.vbox.client.shared.generated.resources.Res +import androiddev.apps.vbox.client.shared.generated.resources.chat_title_app_bar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.jetbrains.compose.resources.stringResource +import ru.shadowsparky.chat.presentation.ChatComponent +import ru.shadowsparky.chat.presentation.ChatContent +import ru.shadowsparky.ui.LocalBlurState +import ru.shadowsparky.ui.applyBlurEffect +import ru.shadowsparky.ui.theme.ComponentDefaults +import ru.shadowsparky.vbox.shared.presentation.BaseMainScreen +import ru.shadowsparky.vbox.shared.presentation.nav.Route + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen(component: ChatComponent, route: Route, onNavigate: (Route) -> Unit) { + val state by component.state.subscribeAsState() + BaseMainScreen( + appBar = { + TopAppBar( + title = { Text(stringResource(Res.string.chat_title_app_bar)) }, + colors = ComponentDefaults.topAppBarColors(), + modifier = Modifier.applyBlurEffect(LocalBlurState.current) + ) + }, + route, + onNavigate = onNavigate, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + content = { ChatContent(component, it, state) } + ) +} \ No newline at end of file diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt index 72467b9..011e40b 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt @@ -37,8 +37,7 @@ class SettingsComponent( val doRegister: () -> Unit, val editPassword: () -> Unit, val showOffline: () -> Unit, - val showTags: () -> Unit, - val showChat: () -> Unit + val showTags: () -> Unit ) : ComponentContext by context { val updateComponent = updateComponentFactory.create(childContext("UpdateComponent")) @@ -100,8 +99,7 @@ class SettingsComponentFactory( tokenStorage = tokenStorage, showOffline = { root.nav(Route.OfflineVideo()) }, offlineSupport = offlineStorage != null, - showTags = { root.nav(Route.Settings.TagOverview()) }, - showChat = { root.nav(Route.Chat())} + showTags = { root.nav(Route.Settings.TagOverview()) } ) return Child.SettingsChild(comp) } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt index 4d9f100..97b48d6 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt @@ -82,12 +82,6 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { ) } } - item { - TextPreference( - text = "chat", - modifier = Modifier.clickable { comp.showChat() } - ) - } } section { val needAuth = tokenInfo == null diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatTextParser.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatTextParser.kt new file mode 100644 index 0000000..1d818c8 --- /dev/null +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatTextParser.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.chat.domain + +interface ChatTextParser { + fun parse(content: String): List +} diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/TextToken.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/TextToken.kt new file mode 100644 index 0000000..e8dfedd --- /dev/null +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/domain/TextToken.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.chat.domain + +sealed interface TextToken { + data class Plain(val text: String) : TextToken + data class Link(val text: String) : TextToken +} diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt index cd6aac2..16b6618 100644 --- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt @@ -11,7 +11,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.ChatRoles +import ru.shadowsparky.chat.domain.ChatTextParser import ru.shadowsparky.chat.domain.Message +import ru.shadowsparky.chat.domain.TextToken sealed interface ChatState { object Loading : ChatState @@ -21,14 +23,15 @@ sealed interface ChatState { data class Data( val messages: List, val isSending: Boolean = false, - val isSubsequentLoading: Boolean = false, val sendError: String? = null ) : ChatState } class ChatComponent( componentContext: ComponentContext, - private val repository: ChatRepository + private val repository: ChatRepository, + private val textParser: ChatTextParser?, + val onLinkClicked: (String) -> Unit ) : ComponentContext by componentContext { private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx -> @@ -42,6 +45,10 @@ class ChatComponent( loadInitialMessages() } + fun parseMessage(content: String): List { + return textParser?.parse(content) ?: listOf(TextToken.Plain(content)) + } + fun loadInitialMessages() { _state.value = ChatState.Loading scope.launch { @@ -54,28 +61,6 @@ class ChatComponent( } } - fun loadMoreMessages() { - val currentState = _state.value as? ChatState.Data ?: return - if (currentState.isSubsequentLoading || currentState.messages.isEmpty()) return - val latestMessageId = currentState.messages.last().id ?: return - _state.value = currentState.copy(isSubsequentLoading = true) - scope.launch { - try { - val newMessages = repository.query(afterId = latestMessageId) - if (newMessages.isNotEmpty()) { - _state.value = currentState.copy( - messages = currentState.messages + newMessages, - isSubsequentLoading = false - ) - } else { - _state.value = currentState.copy(isSubsequentLoading = false) - } - } catch (e: Exception) { - _state.value = currentState.copy(isSubsequentLoading = false) - } - } - } - fun sendMessage(content: String) { val currentState = _state.value as? ChatState.Data ?: return if (content.isBlank() || currentState.isSending) return diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt index 555e797..867e659 100644 --- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt @@ -5,100 +5,109 @@ import androiddev.feature.chat.chat_client.generated.resources.chat_input_placeh import androiddev.feature.chat.chat_client.generated.resources.chat_load_error import androiddev.feature.chat.chat_client.generated.resources.chat_ok import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button -import androiddev.feature.chat.chat_client.generated.resources.chat_send_button -import androiddev.feature.chat.chat_client.generated.resources.chat_title 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.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding 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.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Snackbar +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar 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.setValue +import androidx.compose.runtime.withFrameNanos 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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.jetbrains.compose.resources.stringResource import ru.shadowsparky.chat.domain.ChatRoles import ru.shadowsparky.chat.domain.Message -import ru.shadowsparky.ui.AppScaffold +import ru.shadowsparky.chat.domain.TextToken import ru.shadowsparky.ui.BlurState -import ru.shadowsparky.ui.applyBlurEffect +import ru.shadowsparky.ui.LocalBlurState import ru.shadowsparky.ui.applyBlurSource +import ru.shadowsparky.ui.components.AlertContent import ru.shadowsparky.ui.components.LazyColumn import ru.shadowsparky.ui.components.Loading -import ru.shadowsparky.ui.rememberBlurState -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) { - val state by component.state.subscribeAsState() - val blur = rememberBlurState() - AppScaffold( - appBar = { - TopAppBar( - title = { Text(stringResource(Res.string.chat_title)) }, - modifier = Modifier.applyBlurEffect(blur) +fun ChatContent( + component: ChatComponent, + paddingValues: PaddingValues, + state: ChatState +) { + Box( + modifier = Modifier.fillMaxSize() + .padding( + PaddingValues( + start = paddingValues.calculateStartPadding(LayoutDirection.Ltr), + top = 0.dp, + end = paddingValues.calculateEndPadding(LayoutDirection.Ltr), + bottom = paddingValues.calculateBottomPadding() + ) ) - }, - modifier = modifier - ) { paddingValues -> - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - when (val currentState = state) { - is ChatState.Loading -> { - Loading(Modifier.padding(paddingValues).fillMaxSize()) - } - is ChatState.Error -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(paddingValues) - ) { - Text(text = stringResource(Res.string.chat_load_error), color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { component.loadInitialMessages() }) { - Text(stringResource(Res.string.chat_retry_button)) - } + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + when (state) { + is ChatState.Loading -> { + Loading(Modifier.fillMaxSize()) + } + + is ChatState.Error -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.chat_load_error), + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { component.loadInitialMessages() }) { + Text(stringResource(Res.string.chat_retry_button)) } } - is ChatState.Data -> { - ChatContent( - paddingValues, - blur, - state = currentState, - onLoadMore = { component.loadMoreMessages() }, - onSendMessage = { text -> component.sendMessage(text) }, - onDismissError = { component.clearSendError() } - ) - } + } + is ChatState.Data -> { + ChatContent( + contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()), + LocalBlurState.current, + state = state, + onParseMessage = { component.parseMessage(it) }, + onClickLink = { component.onLinkClicked(it) }, + onSendMessage = { component.sendMessage(it) }, + onDismissError = { component.clearSendError() } + ) } } } @@ -106,31 +115,17 @@ fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) { @Composable private fun ChatContent( - paddingValues: PaddingValues, + contentPadding: PaddingValues, blurState: BlurState?, state: ChatState.Data, - onLoadMore: () -> Unit, + onParseMessage: (String) -> List, + onClickLink: (String) -> Unit, onSendMessage: (String) -> Unit, onDismissError: () -> Unit ) { val listState = rememberLazyListState() var textInput by remember { mutableStateOf("") } - - val shouldLoadMore by remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val totalItems = layoutInfo.totalItemsCount - val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - - totalItems > 0 && lastVisibleItemIndex >= totalItems - 2 - } - } - - LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore) { - onLoadMore() - } - } + val requester = remember { FocusRequester() } LaunchedEffect(state.messages.size) { if (state.messages.isNotEmpty()) { @@ -138,85 +133,109 @@ private fun ChatContent( } } + LaunchedEffect(state.messages) { textInput = "" } + Column(modifier = Modifier.fillMaxSize()) { LazyColumn( state = listState, modifier = Modifier.applyBlurSource(blurState) .weight(1f) .fillMaxWidth(), - contentPadding = paddingValues, + contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(items = state.messages, key = { it.id ?: it.hashCode() }) { message -> - MessageBubble(message = message) - } - - if (state.isSubsequentLoading) { - item { - Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } + MessageBubble( + message = message, + onParseMessage = onParseMessage, + onClickLink = onClickLink + ) } + item { Spacer(Modifier.height(4.dp)) } } - state.sendError?.let { error -> - Snackbar( - action = { - TextButton(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) } + AlertContent( + onDismissRequest = onDismissError, + content = { + Button(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) } }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Text(error) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = textInput, - onValueChange = { textInput = it }, - modifier = Modifier.weight(1f), - placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) }, - enabled = !state.isSending + title = error ) - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { - if (textInput.isNotBlank()) { - onSendMessage(textInput) - textInput = "" - } - }, - enabled = textInput.isNotBlank() && !state.isSending - ) { - if (state.isSending) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - } else { - Text(stringResource(Res.string.chat_send_button)) - } - } } + if (state.isSending) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + OutlinedTextField( + value = textInput, + onValueChange = { textInput = it }, + modifier = Modifier.focusRequester(requester).fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) }, + enabled = !state.isSending, + keyboardActions = KeyboardActions( + onDone = { + if (textInput.isNotBlank()) onSendMessage(textInput) + } + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + LaunchedEffect(null) { + withFrameNanos {} + requester.requestFocus() } } @Composable -private fun MessageBubble(message: Message) { +private fun MessageBubble( + message: Message, + onParseMessage: (String) -> List, + onClickLink: (String) -> Unit +) { val isUser = message.role == ChatRoles.USER val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart val containerColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + val contentColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + val tokens = remember(message.content) { onParseMessage(message.content) } + val annotatedString = buildAnnotatedString { + tokens.forEachIndexed { index, token -> + when (token) { + is TextToken.Plain -> { append(token.text) } + is TextToken.Link -> { + pushStringAnnotation(tag = "LINK_INDEX", annotation = index.toString()) + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline + ) + ) { append(token.text) } + pop() + } + } + } + } + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) { Card( - colors = CardDefaults.cardColors(containerColor = containerColor), + colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor), modifier = Modifier.widthIn(max = 280.dp) ) { Column(modifier = Modifier.padding(12.dp)) { - Text(text = message.content, style = MaterialTheme.typography.bodyLarge) + ClickableText( + text = annotatedString, + style = MaterialTheme.typography.bodyLarge.copy(color = contentColor), + onClick = { offset -> + annotatedString.getStringAnnotations(tag = "LINK_INDEX", start = offset, end = offset) + .firstOrNull()?.let { annotation -> + val tokenIndex = annotation.item.toIntOrNull() ?: return@ClickableText + val clickedToken = tokens.getOrNull(tokenIndex) as? TextToken.Link ?: return@ClickableText + onClickLink(clickedToken.text) + } + } + ) } } } } +