From 5b08ff71f508a96e944262cda27f3d58c77b2af7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 14 Apr 2026 21:40:47 +0300 Subject: [PATCH] Implement suspension state and global banners Signed-off-by: denis0001-dev --- .../composeResources/values-ru/strings.xml | 8 + .../composeResources/values/strings.xml | 8 + .../kotlin/ru/fromchat/api/ApiClient.kt | 86 +++++- .../kotlin/ru/fromchat/api/Models.kt | 4 +- .../kotlin/ru/fromchat/api/ProfileCache.kt | 17 ++ .../ru/fromchat/api/WebSocketManager.kt | 7 +- .../ru/fromchat/api/db/MessageCacheStore.kt | 14 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 41 ++- .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 286 ++++++++++-------- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 34 ++- .../ru/fromchat/ui/chat/MessageContextMenu.kt | 31 +- .../ru/fromchat/ui/chat/MessageDisplayName.kt | 14 + .../ru/fromchat/ui/chat/PublicChatPanel.kt | 33 +- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 32 +- .../ru/fromchat/ui/main/ChatsSearchScreen.kt | 3 +- .../kotlin/ru/fromchat/ui/main/ChatsTab.kt | 20 +- .../ru/fromchat/ui/main/ChatsTabBannerHost.kt | 121 ++++++++ .../ui/main/settings/SettingsScreens.kt | 4 +- .../ru/fromchat/ui/profile/ProfileScreen.kt | 14 +- .../fromchat/ui/setup/ServerConfigScreen.kt | 8 +- .../ui/suspension/SuspendedAccountNotice.kt | 256 ++++++++++++++++ 21 files changed, 858 insertions(+), 183 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTabBannerHost.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/suspension/SuspendedAccountNotice.kt diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 60fb2c4..3816f90 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -204,4 +204,12 @@ %1$s, %2$s и ещё %3$d печатают… Ещё +%1$d + + + Ваш аккаунт был заблокирован + Причина не указана. Доступ к отправке временно отключён. + Аккаунт заблокирован + Ваш аккаунт заблокирован за нарушение правил.\nСейчас вы можете читать чаты, но отправка новых сообщений временно отключена.\nЕсли вы считаете, что это ошибка, свяжитесь с поддержкой и мы поможем разобраться. + Причина + Связаться с поддержкой diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 64ddfd6..b3c22f5 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -238,4 +238,12 @@ More +%1$d + + + Your account was blocked + Reason not provided. Your account access is currently read-only. + Account blocked + Your account was blocked for violating the community rules.\nYou can still read your chats, but new messages are currently paused.\nIf you think this is a mistake, contact support and we can help. + Reason + Contact support diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index c90f036..d08303d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -23,11 +23,15 @@ import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.request.put +import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement import ru.fromchat.core.config.Config @@ -58,6 +62,55 @@ object ApiClient { encodeDefaults = true } + data class SuspensionState( + val isSuspended: Boolean = false, + val reason: String? = null, + ) + + private val _suspensionState = MutableStateFlow(SuspensionState()) + val suspensionState: StateFlow = _suspensionState.asStateFlow() + + private fun normalizedSuspensionReason(reason: String?): String? = + reason?.trim()?.ifEmpty { null } + + private fun syncSuspensionStateFromUser(user: User?) { + _suspensionState.value = SuspensionState( + isSuspended = user?.suspended == true, + reason = normalizedSuspensionReason(user?.suspensionReason) + ) + } + + fun syncSuspensionStateFromProfile(profile: UserProfile?) { + val isSuspended = profile?.suspended == true + val reason = normalizedSuspensionReason(profile?.suspensionReason) + _suspensionState.value = SuspensionState( + isSuspended = isSuspended, + reason = if (isSuspended) reason else null + ) + + user = user?.copy( + suspended = isSuspended, + suspensionReason = if (isSuspended) reason else null + ) + } + + fun clearSuspensionState() { + _suspensionState.value = SuspensionState() + user = user?.copy( + suspended = false, + suspensionReason = null + ) + } + + fun setSuspended(reason: String?) { + val normalizedReason = normalizedSuspensionReason(reason) + _suspensionState.value = SuspensionState(isSuspended = true, reason = normalizedReason) + user = user?.copy( + suspended = true, + suspensionReason = normalizedReason + ) + } + val http = createPlatformHttpClient { install(ContentNegotiation) { json(json) @@ -91,18 +144,24 @@ object ApiClient { header("User-Agent", userAgent) } - // Handle HTTP errors and auth errors globally + // Handle HTTP auth errors globally. + // - 401 => token/session invalid, clear auth state and trigger auth recovery flow. + // - 403 => forbidden, keep session intact (used for read-only/suspension workflows). HttpResponseValidator { validateResponse { response -> - if (response.status.value == 401 || response.status.value == 403) { + if (response.status.value == 401) { token = null user = null + clearSuspensionState() onAuthError?.let { MainScope().launch { it() } } } + if (response.status.value == 403) { + handleForbiddenAsPotentialSuspension(response) + } if (response.status.value !in (200..299) + 101) { throw ClientRequestException( @@ -153,6 +212,17 @@ object ApiClient { // Global auth error handler var onAuthError: (() -> Unit)? = null + private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? { + return response.headers["suspension_reason"]?.trim()?.takeIf { it.isNotBlank() } + } + + private fun handleForbiddenAsPotentialSuspension(response: HttpResponse) { + if (response.status.value != 403) return + getSuspensionReasonFromForbiddenResponse(response)?.let { reason -> + setSuspended(reason) + } + } + // Load persisted token and user info suspend fun loadPersistedData() { try { @@ -162,6 +232,7 @@ object ApiClient { val userInfo = settings.getString("user_info", "") if (userInfo.isNotEmpty()) { user = json.decodeFromString(userInfo) + syncSuspensionStateFromUser(user) } } } catch (e: Exception) { @@ -193,11 +264,13 @@ object ApiClient { fun bindSession(loginResponse: LoginResponse) { token = loginResponse.token user = loginResponse.user + syncSuspensionStateFromUser(user) } fun clearMemorySession() { token = null user = null + clearSuspensionState() } suspend fun persistSessionToStorage(loginResponse: LoginResponse) { @@ -596,6 +669,7 @@ object ApiClient { settings.remove("current_fcm_token") token = null user = null + clearSuspensionState() uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) } UpdateSyncManager.resetInMemoryOnLogout() runCatching { IdentityKeyManager.clearLocalKeys() } @@ -613,6 +687,7 @@ object ApiClient { fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { + if (_suspensionState.value.isSuspended) return http.post("${Config.apiBaseUrl}/send_message") { contentType(ContentType.Application.Json) setBody(SendMessageRequest(content = content, reply_to_id = replyToId)) @@ -621,6 +696,7 @@ object ApiClient { // WebSocket send helpers suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) { + if (_suspensionState.value.isSuspended) return WebSocketManager.send( WebSocketMessage( type = "sendMessage", @@ -640,6 +716,7 @@ object ApiClient { } suspend fun editMessage(messageId: Int, content: String) { + if (_suspensionState.value.isSuspended) return WebSocketManager.send( WebSocketMessage( type = "editMessage", @@ -658,6 +735,7 @@ object ApiClient { } suspend fun deleteMessage(messageId: Int) { + if (_suspensionState.value.isSuspended) return WebSocketManager.send( WebSocketMessage( type = "deleteMessage", @@ -675,6 +753,7 @@ object ApiClient { } suspend fun sendTyping() { + if (_suspensionState.value.isSuspended) return runCatching { WebSocketManager.send( WebSocketMessage( @@ -689,6 +768,7 @@ object ApiClient { } suspend fun sendStopTyping() { + if (_suspensionState.value.isSuspended) return runCatching { WebSocketManager.send( WebSocketMessage( @@ -703,6 +783,7 @@ object ApiClient { } suspend fun sendDmTyping(recipientId: Int) { + if (_suspensionState.value.isSuspended) return runCatching { WebSocketManager.send( WebSocketMessage( @@ -718,6 +799,7 @@ object ApiClient { } suspend fun sendStopDmTyping(recipientId: Int) { + if (_suspensionState.value.isSuspended) return runCatching { WebSocketManager.send( WebSocketMessage( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index c8f5931..b8f4742 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -33,7 +33,9 @@ data class User( @SerialName("display_name") val displayName: String? = null, val admin: Boolean? = null, val bio: String? = null, - val profile_picture: String? = null + val profile_picture: String? = null, + val suspended: Boolean? = null, + @SerialName("suspension_reason") val suspensionReason: String? = null ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt index dfe6615..c062d5e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt @@ -11,6 +11,23 @@ import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import kotlin.concurrent.Volatile +/** + * Returns true when a profile entry should not expose its `username` field + * to the UI (for suspended or deleted users), except for the current user. + */ +fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean = + id != currentUserId && (deleted == true || suspended == true) + +fun UserProfile.visibleUsername(currentUserId: Int? = null): String? = + if (shouldHideUsername(currentUserId)) { + null + } else { + username.trim().takeIf { it.isNotBlank() } + } + +fun UserProfile.visibleDisplayName(currentUserId: Int? = null): String? = + displayName?.trim()?.ifEmpty { null } ?: visibleUsername(currentUserId) + /** * In-memory profile cache with disk persistence. [get] reads a volatile snapshot (lock-free). * [put] copy-on-writes the map and schedules an async flush of the full list to settings. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt index a6d8d63..c033510 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt @@ -293,16 +293,19 @@ object WebSocketManager { } fun shutdown() { + disconnect() logD("shutdown() called. Cancelling scope.") scope.cancel() } fun disconnect() { - logD("disconnect() called. current session=${session != null}") + logD("disconnect() called. current session=${session != null}, connectionJobActive=${connectionJob?.isActive}") + connectionJob?.cancel() + connectionJob = null session?.cancel() session = null connecting = false - logD("Disconnected. session set to null, connecting set to false") + logD("Disconnected. session set to null, connecting set to false, connectionJob set to null") } fun onNetworkLost() { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt index 5d65b3e..67ed736 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt @@ -103,10 +103,18 @@ object MessageCacheStore { replaceMessages(conversationIdForPublic(), merged) } + suspend fun clearPublicMessages() { + clearConversationMessages(conversationIdForPublic()) + } + suspend fun loadDmMessages(otherUserId: Int): List { return loadMessages(conversationIdForDm(otherUserId)) } + suspend fun clearDmMessages(otherUserId: Int) { + clearConversationMessages(conversationIdForDm(otherUserId)) + } + suspend fun replaceDmMessages(otherUserId: Int, messages: List) { val convId = conversationIdForDm(otherUserId) val pending = loadPendingMessages(convId) @@ -151,6 +159,12 @@ object MessageCacheStore { confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed) } + private suspend fun clearConversationMessages(conversationId: String) { + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.deleteMessagesForConversation(conversationId) + } + } + private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) { withContext(Dispatchers.Default) { db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index b8bb3df..eef170f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -59,6 +59,8 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import ru.fromchat.ui.main.settings.SettingsAccountScreen import ru.fromchat.ui.main.settings.SettingsAppearanceScreen import ru.fromchat.ui.main.settings.SettingsDevicesScreen @@ -87,14 +89,42 @@ private fun handlePresenceTyping(type: String, data: JsonObject?) { } } +private fun extractReason(data: JsonElement?): String? { + return data?.jsonObject?.get("reason")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } +} + +private fun handleAccountLifecycleEvent(message: WebSocketMessage) { + when (message.type) { + "suspended" -> { + val reason = extractReason(message.data) + ApiClient.setSuspended(reason) + } + "unsuspended" -> { + ApiClient.clearSuspensionState() + } + "account_deleted" -> { + MainScope().launch { + ApiClient.logout() + } + WebSocketManager.disconnect() + } + } +} + private fun handlePresenceEvent(message: WebSocketMessage) { when (message.type) { + "suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message) "statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus) "dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) } "updates" -> { val data = message.data ?: return val updates = ApiClient.json.decodeFromJsonElement(data) - updates.updates.forEach(::handlePresenceEvent) + updates.updates.forEach { update -> + when (update.type) { + "suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(update) + else -> handlePresenceEvent(update) + } + } } } } @@ -150,6 +180,14 @@ fun App( runCatching { ProfileCache.hydrateFromDisk() } + val hasTokenInitially = ApiClient.token?.isNotEmpty() == true + if (hasTokenInitially) { + runCatching { + val ownProfile = ApiClient.getOwnProfile() + ApiClient.syncSuspensionStateFromProfile(ownProfile) + } + } + // Initialize update sync state for the current user (if any) runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) @@ -279,6 +317,7 @@ fun App( composable("login") { LoginScreen( onLoginSuccess = { + WebSocketManager.connect(forceRestart = true) navController.navigate("chat") { popUpTo("login") { inclusive = true } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index fb7d084..b74f0e0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -56,6 +57,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp @@ -209,7 +211,9 @@ fun ChatInput( onClearEdit: () -> Unit, hazeState: HazeState, recipientId: Int? = null, - currentUserId: Int? = null + currentUserId: Int? = null, + isReadOnly: Boolean = false, + onReadOnlyMessageClick: () -> Unit = {} ) { val scope = rememberCoroutineScope() var typingJob by remember { mutableStateOf(null) } @@ -253,7 +257,7 @@ fun ChatInput( } } - val canSend = text.isNotBlank() || attachments.isNotEmpty() + val canSend = !isReadOnly && (text.isNotBlank() || attachments.isNotEmpty()) val cdClose = stringResource(Res.string.cd_close) val cdRemove = stringResource(Res.string.cd_remove) val cdPickImage = stringResource(Res.string.cd_pick_image) @@ -261,6 +265,7 @@ fun ChatInput( val cdSend = stringResource(Res.string.cd_send) val corruptedShort = stringResource(Res.string.message_corrupted_short) val editingTitle = stringResource(Res.string.message_editing_title) + val blockedMessage = stringResource(Res.string.suspend_chat_banner_message) Box( modifier = Modifier @@ -284,142 +289,163 @@ fun ChatInput( state = hazeState, style = HazeMaterials.thin() ) + .clickable(enabled = isReadOnly) { + if (isReadOnly) { + onReadOnlyMessageClick() + } + } ) { - AnimatedPreviewBar(replyTo) { replyTo -> - val replySubtitle = if (replyTo.isContentCorrupted) { - corruptedShort - } else { - replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" - } - val replyName = messageDisplayUsername(replyTo, currentUserId) - PreviewBar( - icon = Icons.AutoMirrored.Filled.Reply, - title = stringResource(Res.string.message_replying_to, replyName), - subtitle = replySubtitle, - closeContentDescription = cdClose, - onClose = { onClearReply() } - ) - } - - AnimatedPreviewBar(editingMessage) { message -> - val subtitle = if (message.isContentCorrupted) { - corruptedShort - } else { - message.content.take(50) + if (message.content.length > 50) "..." else "" - } - PreviewBar( - icon = Icons.Filled.Edit, - title = editingTitle, - subtitle = subtitle, - closeContentDescription = cdClose, - onClose = { onClearEdit() } - ) - } - - AnimatedVisibility( - visible = attachments.isNotEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Row( + if (isReadOnly) { + Text( + text = blockedMessage, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(start = 12.dp, end = 12.dp, top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) + } else { + AnimatedPreviewBar(replyTo) { replyTo -> + val replySubtitle = if (replyTo.isContentCorrupted) { + corruptedShort + } else { + replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" + } + val replyName = messageDisplayUsername(replyTo, currentUserId) + PreviewBar( + icon = Icons.AutoMirrored.Filled.Reply, + title = stringResource(Res.string.message_replying_to, replyName), + subtitle = replySubtitle, + closeContentDescription = cdClose, + onClose = { onClearReply() } + ) + } + + AnimatedPreviewBar(editingMessage) { message -> + val subtitle = if (message.isContentCorrupted) { + corruptedShort + } else { + message.content.take(50) + if (message.content.length > 50) "..." else "" + } + PreviewBar( + icon = Icons.Filled.Edit, + title = editingTitle, + subtitle = subtitle, + closeContentDescription = cdClose, + onClose = { onClearEdit() } + ) + } + + AnimatedVisibility( + visible = attachments.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() ) { - attachments.forEach { attachment -> - AttachmentChip( - attachment = attachment, - onRemove = { attachments = attachments.filter { it.id != attachment.id } }, - removeContentDescription = cdRemove - ) - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom - ) { - if (recipientId != null) { - IconButton(onClick = { launchImagePicker() }) { - Icon( - imageVector = Icons.Default.Image, - contentDescription = cdPickImage, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - IconButton(onClick = { launchFilePicker() }) { - Icon( - imageVector = Icons.Default.AttachFile, - contentDescription = cdPickFile, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - OutlinedTextField( - value = text, - onValueChange = onTextChange, - modifier = Modifier - .weight(1f) - .animateContentSize(), - placeholder = { - Text( - text = stringResource(Res.string.message_placeholder), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - }, - shape = shape, - maxLines = 5, - singleLine = false, - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - errorContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - errorBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent - ), - trailingIcon = { - val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() } - - AnimatedVisibility( - visible = canSend, - enter = slideInHorizontally( - initialOffsetX = { it + offset }, - animationSpec = tween(durationMillis = 300) - ), - exit = slideOutHorizontally( - targetOffsetX = { it + offset }, - animationSpec = tween(durationMillis = 200) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(start = 12.dp, end = 12.dp, top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + attachments.forEach { attachment -> + AttachmentChip( + attachment = attachment, + onRemove = { attachments = attachments.filter { it.id != attachment.id } }, + removeContentDescription = cdRemove ) - ) { - Box(Modifier.padding(end = 5.dp)) { - FilledIconButton( - onClick = { - val plaintext = text.trim().ifBlank { "" } - onSend(plaintext, attachments) - onTextChange("") - attachments = emptyList() - typingHandler.stopTyping() - }, - modifier = Modifier.size(36.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = cdSend, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp) - ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + if (recipientId != null && !isReadOnly) { + IconButton(onClick = { launchImagePicker() }) { + Icon( + imageVector = Icons.Default.Image, + contentDescription = cdPickImage, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = { launchFilePicker() }) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = cdPickFile, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + OutlinedTextField( + value = text, + onValueChange = onTextChange, + enabled = !isReadOnly, + modifier = Modifier + .weight(1f) + .animateContentSize(), + placeholder = { + Text( + text = stringResource(Res.string.message_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + }, + shape = shape, + maxLines = 5, + singleLine = false, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + errorBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + trailingIcon = { + val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() } + + AnimatedVisibility( + visible = canSend, + enter = slideInHorizontally( + initialOffsetX = { it + offset }, + animationSpec = tween(durationMillis = 300) + ), + exit = slideOutHorizontally( + targetOffsetX = { it + offset }, + animationSpec = tween(durationMillis = 200) + ) + ) { + Box(Modifier.padding(end = 5.dp)) { + FilledIconButton( + onClick = { + if (isReadOnly) { + return@FilledIconButton + } + val plaintext = text.trim().ifBlank { "" } + onSend(plaintext, attachments) + onTextChange("") + attachments = emptyList() + typingHandler.stopTyping() + }, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = cdSend, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp) + ) + } } } } - } - ) + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 1f048c0..961808c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -94,6 +94,7 @@ import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.chat.getImageAspectRatio import ru.fromchat.ui.scaleOnPress +import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.rememberLastSeenFormatStrings import kotlin.time.Clock @@ -145,7 +146,10 @@ fun ChatScreen( val statusMap by UserStatusStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) + val suspensionState by ApiClient.suspensionState.collectAsState() + val isReadOnly = suspensionState.isSuspended val lastSeenFormat = rememberLastSeenFormatStrings() + var showSuspendedSupportSheet by remember { mutableStateOf(false) } val statusConnecting = stringResource(Res.string.status_connecting) val statusUpdating = stringResource(Res.string.status_updating) val chatGroupLabel = stringResource(Res.string.chat_group_label) @@ -154,6 +158,12 @@ fun ChatScreen( Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") } + LaunchedEffect(isReadOnly) { + if (!isReadOnly) { + showSuspendedSupportSheet = false + } + } + // Subscribe to other user's status when DM is visible; unsubscribe on leave LaunchedEffect(panelState.profileUserId) { val userId = panelState.profileUserId @@ -186,7 +196,7 @@ fun ChatScreen( var inputText by rememberSaveable { mutableStateOf("") } var replyTo by rememberSaveable { mutableStateOf(null) } var editingMessage by rememberSaveable { mutableStateOf(null) } - var contextMenuState by remember { + var contextMenuState by remember { mutableStateOf( ContextMenuState( isOpen = false, @@ -236,7 +246,7 @@ fun ChatScreen( "newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted", "typing", "stopTyping", "dmTyping", "stopDmTyping", - "suspended", "account_deleted", "registeredUserCount" -> { + "registeredUserCount" -> { Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}") try { panel.handleWebSocketMessage(wsMessage) @@ -323,6 +333,11 @@ fun ChatScreen( Box(modifier = Modifier.fillMaxSize()) { + SuspendedAccountSupportSheet( + isVisible = isReadOnly && showSuspendedSupportSheet, + onDismissRequest = { showSuspendedSupportSheet = false } + ) + Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -526,7 +541,7 @@ fun ChatScreen( } }, actions = { - if (panel.showCallButton()) { + if (panel.showCallButton() && !isReadOnly) { IconButton(onClick = { /* TODO: Handle call */ }) { Icon( imageVector = Icons.Default.Call, @@ -642,7 +657,13 @@ fun ChatScreen( inputText = "" }, hazeState = hazeState, - recipientId = panel.getRecipientId() + recipientId = panel.getRecipientId(), + isReadOnly = isReadOnly, + onReadOnlyMessageClick = { + if (isReadOnly) { + showSuspendedSupportSheet = true + } + } ) } } @@ -691,6 +712,9 @@ fun ChatScreen( isContextMenuOpen = contextMenuState.isOpen, isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id, onLongPress = { + if (isReadOnly) { + return@MessageItem + } haptic(HapticFeedbackEvent.ContextMenuOpened) contextMenuState = ContextMenuState( isOpen = true, @@ -756,6 +780,7 @@ fun ChatScreen( MessageContextMenu( state = contextMenuState, isAuthor = contextMenuState.message?.user_id == currentUserId, + isReadOnly = isReadOnly, screenWidthPx = screenWidthPx, screenHeightPx = screenHeightPx, onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) }, @@ -815,4 +840,5 @@ fun ChatScreen( ) } } + } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 4ff9737..4e73704 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -67,10 +67,20 @@ fun MessageContextMenu( onReply: (Message) -> Unit, onEdit: (Message) -> Unit, onDelete: (Message) -> Unit, + isReadOnly: Boolean = false, screenWidthPx: Int, screenHeightPx: Int, modifier: Modifier = Modifier, ) { + if (isReadOnly && state.isOpen) { + onDismiss() + return + } + + if (isReadOnly) { + return + } + var shouldShowPopup by remember(state.message) { mutableStateOf(state.isOpen && state.message != null) } @@ -119,6 +129,7 @@ fun MessageContextMenu( modifier = modifier.graphicsLayer(alpha = 0f), animated = false, withShadow = false, + isReadOnly = isReadOnly, ) }.map { it.measure(looseConstraints) } val p = placeables.firstOrNull() @@ -206,7 +217,8 @@ fun MessageContextMenu( scale = scale, alpha = alpha, transformOriginX = transformOriginX, - transformOriginY = transformOriginY + transformOriginY = transformOriginY, + isReadOnly = isReadOnly ) } } @@ -220,6 +232,7 @@ private fun ContextMenuContent( onReply: (Message) -> Unit, onEdit: (Message) -> Unit, onDelete: (Message) -> Unit, + isReadOnly: Boolean = false, modifier: Modifier, animated: Boolean, withShadow: Boolean = true, @@ -272,19 +285,21 @@ private fun ContextMenuContent( .verticalScroll(menuScrollState), verticalArrangement = Arrangement.spacedBy(itemSpacing) ) { - ContextMenuItem( - icon = Icons.AutoMirrored.Filled.Reply, - text = labelReply, - onClick = { onReply(message) } - ) - if (isAuthor) { + if (!isReadOnly) { + ContextMenuItem( + icon = Icons.AutoMirrored.Filled.Reply, + text = labelReply, + onClick = { onReply(message) } + ) + } + if (isAuthor && !isReadOnly) { ContextMenuItem( icon = Icons.Default.Edit, text = labelEdit, onClick = { onEdit(message) } ) } - if (isAuthor) { + if (isAuthor && !isReadOnly) { ContextMenuItem( icon = Icons.Default.Delete, text = labelDelete, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt index 3dd8f87..9412334 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt @@ -3,6 +3,8 @@ package ru.fromchat.ui.chat import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.api.ProfileCache +import ru.fromchat.api.visibleUsername import ru.fromchat.api.Message import ru.fromchat.* @@ -16,6 +18,18 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String { if (currentUserId != null && message.user_id == currentUserId) { return stringResource(Res.string.message_sender_you) } + val cachedProfile = ProfileCache.get(message.user_id) + val isCachedUserHidden = cachedProfile?.let { + it.id != currentUserId && (it.deleted == true || it.suspended == true) + } == true + if (isCachedUserHidden) { + return stringResource(Res.string.user_fallback, message.user_id) + } + val cachedUsername = cachedProfile?.visibleUsername(currentUserId) + if (cachedUsername != null) return cachedUsername + if (message.username.equals("deleted", ignoreCase = true)) { + return stringResource(Res.string.user_fallback, message.user_id) + } val m = userIdUsernamePattern.matchEntire(message.username) if (m != null) { val id = m.groupValues[1].toIntOrNull() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index c0d630c..c4bfc69 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import io.ktor.client.plugins.ClientRequestException import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient @@ -189,9 +190,11 @@ class PublicChatPanel( } // 2) Refresh from network; this may be fast or slow, but runs entirely off main. - val response = withContext(Dispatchers.Default) { - runCatching { ApiClient.getMessages(limit = 50) }.getOrNull() + val responseResult = withContext(Dispatchers.Default) { + runCatching { ApiClient.getMessages(limit = 50) } } + val response = responseResult.getOrNull() + if (response != null && response.messages.isNotEmpty()) { withContext(Dispatchers.Main) { val shown = _state.messages @@ -211,17 +214,33 @@ class PublicChatPanel( withContext(Dispatchers.Default) { MessageCacheStore.replacePublicMessages(response.messages) } + } else if (responseResult.isFailure) { + val cause = responseResult.exceptionOrNull() + if (cause is ClientRequestException && cause.response.status.value == 403) { + MessageCacheStore.clearPublicMessages() + withContext(Dispatchers.Main) { + clearMessages() + if (_state.isLoading) setLoading(false) + if (_state.hasMoreMessages) setHasMoreMessages(false) + } + } else if (cached.isEmpty()) { + // Nothing to show at all; hide spinner so the user is not stuck. + withContext(Dispatchers.Main) { + if (_state.isLoading) setLoading(false) + if (_state.hasMoreMessages) setHasMoreMessages(false) + } + } else { + // We already displayed cached messages; just mark pagination state. + withContext(Dispatchers.Main) { + if (_state.hasMoreMessages) setHasMoreMessages(false) + } + } } else if (cached.isEmpty()) { // Nothing to show at all; hide spinner so the user is not stuck. withContext(Dispatchers.Main) { if (_state.isLoading) setLoading(false) if (_state.hasMoreMessages) setHasMoreMessages(false) } - } else { - // We already displayed cached messages; just mark pagination state. - withContext(Dispatchers.Main) { - if (_state.hasMoreMessages) setHasMoreMessages(false) - } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 2e52558..6eabd30 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import io.ktor.client.plugins.ClientRequestException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -16,6 +17,7 @@ import ru.fromchat.api.ApiClient import ru.fromchat.api.DmEnvelope import ru.fromchat.api.Message import ru.fromchat.api.ProfileCache +import ru.fromchat.api.visibleDisplayName import ru.fromchat.api.WebSocketMessage import ru.fromchat.core.Logger import ru.fromchat.crypto.CorruptedDmMessagePlaceholder @@ -83,7 +85,7 @@ class DmPanel( return@onSuccess } ProfileCache.put(profile) - val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username + val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty() otherDisplayName = displayName otherProfilePicture = profile.profilePicture updateState { @@ -130,19 +132,15 @@ class DmPanel( override suspend fun loadMessages() { setLoading(true) try { - // First, hydrate from cache if available. - runCatching { - val cached = MessageCacheStore.loadDmMessages(otherUserId) - if (cached.isNotEmpty()) { - clearMessages() - addMessages(cached) - } + val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) + if (cached.isNotEmpty()) { + clearMessages() + addMessages(cached) } - // Then refresh from network. - runCatching { - ApiClient.getDmHistory(otherUserId) - }.onSuccess { response -> + val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) } + if (historyResult.isSuccess) { + val response = historyResult.getOrNull() ?: return@loadMessages clearMessages() val decryptedForLog = mutableListOf>() val messages = response.messages.map { envelope -> @@ -165,8 +163,14 @@ class DmPanel( // Persist the most recent DM messages for offline use. MessageCacheStore.replaceDmMessages(otherUserId, messagesWithReplies) - }.onFailure { error -> - Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error) + } else { + val error = historyResult.exceptionOrNull() + Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error) + if (error is ClientRequestException && error.response.status.value == 403) { + MessageCacheStore.clearDmMessages(otherUserId) + clearMessages() + setHasMoreMessages(false) + } } } finally { setLoading(false) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt index 2de3821..82d1426 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt @@ -67,6 +67,7 @@ import ru.fromchat.resolveSearchHintResource import ru.fromchat.resolveSearchTitleResource import ru.fromchat.resolveSearchNotFoundMessageResource import ru.fromchat.resolveSearchNotFoundTitleResource +import ru.fromchat.api.visibleUsername import ru.fromchat.ui.BackHandler import ru.fromchat.ui.M3SearchBar import ru.fromchat.ui.SearchBarSharedElement @@ -271,7 +272,7 @@ private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery val candidates = buildList { add(conv.displayName) cached?.displayName?.let { add(it) } - cached?.username?.let { add(it) } + cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) } } return candidates.any { candidate -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt index 4201761..277a7c3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt @@ -79,9 +79,12 @@ import ru.fromchat.ui.SearchBarSharedElement import ru.fromchat.ui.branding.FromChatBrandTitle import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator +import ru.fromchat.ui.suspension.* import ru.fromchat.ui.dm.DmNav import ru.fromchat.unread_count import ru.fromchat.user_fallback +import ru.fromchat.api.visibleUsername +import ru.fromchat.* @Composable private fun ChatRowAvatar( @@ -126,6 +129,7 @@ fun ChatsTab( val statusMap by UserStatusStore.status.collectAsState() var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } val statusSubscriptionScope = rememberCoroutineScope() + val suspensionState by ApiClient.suspensionState.collectAsState() val publicConversationsOffset = 1 LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) { @@ -202,6 +206,8 @@ fun ChatsTab( val updatingTitle = stringResource(Res.string.status_updating) val brandTitle = stringResource(Res.string.app_name) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) + val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) + val suspendDefaultReason = stringResource(Res.string.suspended_default_reason) val publicChatTitle = stringResource(Res.string.public_chat) Scaffold( @@ -320,6 +326,15 @@ fun ChatsTab( sharedElementKey = SearchBarSharedElement ) + SuspendedAccountNoticeHost( + isSuspended = suspensionState.isSuspended, + reason = suspensionState.reason, + fallbackReason = suspendDefaultReason, + bannerTitle = suspendBannerTitle, + style = SuspendedAccountBannerStyle.Tabs, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + ChatConversationsList( listState = tabListState, conversations = dmConversations, @@ -342,6 +357,7 @@ fun ChatsTab( ) } } + } @Composable @@ -439,7 +455,7 @@ private fun DmConversationRow( val cached = ProfileCache.get(conversation.otherUserId) val avatarUrl = cached?.profilePicture val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } - ?: cached?.username?.takeIf { it.isNotBlank() } + ?: cached?.visibleUsername(ApiClient.user?.id) ?: conversation.displayName.ifBlank { stringResource(Res.string.user_fallback, conversation.otherUserId) } @@ -511,7 +527,7 @@ private fun matchesDmSearch(conv: CachedConversation, normalizedQuery: String): val candidates = buildList { add(conv.displayName) cached?.displayName?.let { add(it) } - cached?.username?.let { add(it) } + cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) } } return candidates.any { candidate -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTabBannerHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTabBannerHost.kt new file mode 100644 index 0000000..8af4fb5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTabBannerHost.kt @@ -0,0 +1,121 @@ +package ru.fromchat.ui.main + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape +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.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +data class ChatsTabBannerCandidate( + val id: String, + val priority: Int, + val title: String, + val message: String, + val icon: String = "ⓘ", + val onTap: () -> Unit = {}, + val onDismiss: (() -> Unit)? = null, +) + +@Composable +fun ChatsTabBannerHost( + candidates: List, + modifier: Modifier = Modifier +) { + val dismissedByUser = remember { mutableStateMapOf() } + + val sortedVisibleCandidates = remember(candidates, dismissedByUser.size) { + candidates + .filter { candidate -> + val canDismiss = candidate.onDismiss != null + !canDismiss || dismissedByUser[candidate.id] != true + } + .sortedByDescending { it.priority } + } + val activeCandidate = sortedVisibleCandidates.firstOrNull() + + AnimatedVisibility(activeCandidate != null) { + activeCandidate?.let { candidate -> + ChatsTabBanner( + candidate = candidate, + modifier = modifier.padding( + horizontal = 12.dp, + vertical = 8.dp + ), + onDismiss = { + if (candidate.onDismiss != null) { + dismissedByUser[candidate.id] = true + candidate.onDismiss.invoke() + } + } + ) + } + } +} + +@Composable +private fun ChatsTabBanner( + candidate: ChatsTabBannerCandidate, + modifier: Modifier = Modifier, + onDismiss: () -> Unit +) { + val canDismiss = candidate.onDismiss != null + + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(20.dp), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = candidate.onTap) + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = candidate.icon, + style = MaterialTheme.typography.titleMedium + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = candidate.title, + style = MaterialTheme.typography.labelLarge + ) + Text( + text = candidate.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3 + ) + } + if (canDismiss) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss banner" + ) + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt index 7c0255d..d682ed5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt @@ -1954,7 +1954,7 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePass onClick = { scope.launch { runCatching { ApiClient.logout() } - WebSocketManager.shutdown() + WebSocketManager.disconnect() onLogout() } }, @@ -1992,7 +1992,7 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePass scope.launch { runCatching { ApiClient.deleteAccount() - WebSocketManager.shutdown() + WebSocketManager.disconnect() ApiClient.clearLocalSession() onLogout() }.onFailure { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 65188b7..3751979 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -75,6 +75,8 @@ import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.ProfileCache import ru.fromchat.api.UserProfile +import ru.fromchat.api.visibleDisplayName +import ru.fromchat.api.visibleUsername import ru.fromchat.api.UserStatus import ru.fromchat.api.UserStatusStore import ru.fromchat.ui.LocalNavController @@ -232,10 +234,12 @@ fun ProfileScreen( ) { val loadError = state.error val profile = state.profile - val displayName = profile?.displayName?.takeIf { it.isNotBlank() } - ?: profile?.username?.takeIf { it.isNotBlank() } + val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id + val displayName = + profile?.visibleDisplayName(currentProfileUserId) ?: initialDisplayName?.takeIf { it.isNotBlank() } ?: "?" + val usernameForLinks = profile?.visibleUsername(currentProfileUserId) val navSharedAvatarKey = if (useSharedElementFromNavigation && targetUserId != null && sharedSourceMessageId != -1) { @@ -334,7 +338,7 @@ fun ProfileScreen( if (compactIdentityForPublicChat) { "https://fromchat.ru/?u=${profile.id}" } else { - profile.username.takeIf { it.isNotBlank() } + usernameForLinks ?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=${profile.id}" } @@ -515,7 +519,7 @@ fun ProfileScreen( } val showDetailsUsername = - !compactIdentityForPublicChat && profile.username.isNotBlank() + !compactIdentityForPublicChat && usernameForLinks != null val showDetailsMemberSince = !compactIdentityForPublicChat && !profile.createdAt.isNullOrBlank() @@ -531,7 +535,7 @@ fun ProfileScreen( if (showDetailsUsername) { ListItem( headline = headlineUsername, - supportingText = profile.username, + supportingText = usernameForLinks.orEmpty(), divider = true, dividerColor = CategoryDefaults.dividerColor, dividerThickness = CategoryDefaults.dividerThickness, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt index 1276e99..1d9f51e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt @@ -151,11 +151,11 @@ fun ServerConfigScreen() { } // Clear API client state - ApiClient.token = null - ApiClient.user = null + ApiClient.clearMemorySession() - // Shutdown WebSocket (will reconnect on login) - WebSocketManager.shutdown() + // Restart websocket connection flow so it uses the new server config + WebSocketManager.disconnect() + WebSocketManager.connect(forceRestart = true) // Navigate to login and wipe entire back stack navController.navigateAndWipeBackStack("login") diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/suspension/SuspendedAccountNotice.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/suspension/SuspendedAccountNotice.kt new file mode 100644 index 0000000..ec7ba67 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/suspension/SuspendedAccountNotice.kt @@ -0,0 +1,256 @@ +package ru.fromchat.ui.suspension + +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.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.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material3.Button +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Icon +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.text.style.TextAlign +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.* + +enum class SuspendedAccountBannerStyle { + Tabs, + ChatInput +} + +@Composable +fun SuspendedAccountBanner( + reason: String, + title: String, + onTap: () -> Unit, + modifier: Modifier = Modifier, + style: SuspendedAccountBannerStyle = SuspendedAccountBannerStyle.ChatInput +) { + when (style) { + SuspendedAccountBannerStyle.Tabs -> { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(20.dp), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onTap) + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🚫", + style = MaterialTheme.typography.titleMedium + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge + ) + Text( + text = reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3 + ) + } + } + } + } + + SuspendedAccountBannerStyle.ChatInput -> { + Column( + modifier = modifier + .fillMaxWidth() + .clickable { onTap() } + .background( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium + ) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.9f), + maxLines = 2 + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun SuspendedAccountSupportSheet( + isVisible: Boolean, + onDismissRequest: () -> Unit, +) { + if (!isVisible) return + + val sheetData by ApiClient.suspensionState.collectAsState() + val title = stringResource(Res.string.suspended_sheet_title) + val description = stringResource(Res.string.suspended_sheet_desc) + val reason = sheetData.reason?.ifBlank { null } ?: stringResource(Res.string.suspended_default_reason) + val supportButtonLabel = stringResource(Res.string.suspended_sheet_action_contact_support) + val closeLabel = stringResource(Res.string.cd_close) + + val uriHandler = LocalUriHandler.current + val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") } + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val closeSheet: () -> Unit = { + scope.launch { + runCatching { sheetState.hide() } + onDismissRequest() + } + } + + ModalBottomSheet( + onDismissRequest = closeSheet, + sheetState = sheetState, + dragHandle = null + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + BottomSheetDefaults.DragHandle() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.size(72.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.errorContainer + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Block, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(40.dp) + ) + } + } + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Text( + text = reason, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium + ) + .padding(12.dp), + textAlign = TextAlign.Center + ) + Button( + onClick = { + scope.launch { + runCatching { sheetState.hide() } + onContact() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = supportButtonLabel) + } + OutlinedButton( + onClick = closeSheet, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = closeLabel) + } + } + } +} + +@Composable +fun SuspendedAccountNoticeHost( + isSuspended: Boolean, + reason: String?, + fallbackReason: String, + bannerTitle: String, + style: SuspendedAccountBannerStyle, + modifier: Modifier = Modifier, +) { + if (!isSuspended) return + + var showSuspensionSheet by remember { mutableStateOf(false) } + val resolvedReason = reason?.takeIf { it.isNotBlank() } ?: fallbackReason + + SuspendedAccountBanner( + title = bannerTitle, + reason = resolvedReason, + onTap = { showSuspensionSheet = true }, + modifier = modifier, + style = style + ) + SuspendedAccountSupportSheet( + isVisible = showSuspensionSheet, + onDismissRequest = { showSuspensionSheet = false } + ) +}