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 }
+ )
+}