Implement suspension state and global banners

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-14 21:40:47 +03:00
Unverified
parent 356b5b709b
commit 5b08ff71f5
21 changed files with 858 additions and 183 deletions
@@ -204,4 +204,12 @@
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string> <string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
<string name="more">Ещё</string> <string name="more">Ещё</string>
<string name="unread_count">+%1$d</string> <string name="unread_count">+%1$d</string>
<!-- Удаления доступа -->
<string name="suspend_chat_banner_message">Ваш аккаунт был заблокирован</string>
<string name="suspended_default_reason">Причина не указана. Доступ к отправке временно отключён.</string>
<string name="suspended_sheet_title">Аккаунт заблокирован</string>
<string name="suspended_sheet_desc">Ваш аккаунт заблокирован за нарушение правил.\nСейчас вы можете читать чаты, но отправка новых сообщений временно отключена.\nЕсли вы считаете, что это ошибка, свяжитесь с поддержкой и мы поможем разобраться.</string>
<string name="suspended_sheet_reason_label">Причина</string>
<string name="suspended_sheet_action_contact_support">Связаться с поддержкой</string>
</resources> </resources>
@@ -238,4 +238,12 @@
<string name="more">More</string> <string name="more">More</string>
<string name="unread_count">+%1$d</string> <string name="unread_count">+%1$d</string>
<!-- Suspension -->
<string name="suspend_chat_banner_message">Your account was blocked</string>
<string name="suspended_default_reason">Reason not provided. Your account access is currently read-only.</string>
<string name="suspended_sheet_title">Account blocked</string>
<string name="suspended_sheet_desc">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.</string>
<string name="suspended_sheet_reason_label">Reason</string>
<string name="suspended_sheet_action_contact_support">Contact support</string>
</resources> </resources>
@@ -23,11 +23,15 @@ import io.ktor.client.request.parameter
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.request.put 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.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch 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.Json
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
@@ -58,6 +62,55 @@ object ApiClient {
encodeDefaults = true encodeDefaults = true
} }
data class SuspensionState(
val isSuspended: Boolean = false,
val reason: String? = null,
)
private val _suspensionState = MutableStateFlow(SuspensionState())
val suspensionState: StateFlow<SuspensionState> = _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 { val http = createPlatformHttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(json) json(json)
@@ -91,18 +144,24 @@ object ApiClient {
header("User-Agent", userAgent) 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 { HttpResponseValidator {
validateResponse { response -> validateResponse { response ->
if (response.status.value == 401 || response.status.value == 403) { if (response.status.value == 401) {
token = null token = null
user = null user = null
clearSuspensionState()
onAuthError?.let { onAuthError?.let {
MainScope().launch { MainScope().launch {
it() it()
} }
} }
} }
if (response.status.value == 403) {
handleForbiddenAsPotentialSuspension(response)
}
if (response.status.value !in (200..299) + 101) { if (response.status.value !in (200..299) + 101) {
throw ClientRequestException( throw ClientRequestException(
@@ -153,6 +212,17 @@ object ApiClient {
// Global auth error handler // Global auth error handler
var onAuthError: (() -> Unit)? = null 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 // Load persisted token and user info
suspend fun loadPersistedData() { suspend fun loadPersistedData() {
try { try {
@@ -162,6 +232,7 @@ object ApiClient {
val userInfo = settings.getString("user_info", "") val userInfo = settings.getString("user_info", "")
if (userInfo.isNotEmpty()) { if (userInfo.isNotEmpty()) {
user = json.decodeFromString(userInfo) user = json.decodeFromString(userInfo)
syncSuspensionStateFromUser(user)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -193,11 +264,13 @@ object ApiClient {
fun bindSession(loginResponse: LoginResponse) { fun bindSession(loginResponse: LoginResponse) {
token = loginResponse.token token = loginResponse.token
user = loginResponse.user user = loginResponse.user
syncSuspensionStateFromUser(user)
} }
fun clearMemorySession() { fun clearMemorySession() {
token = null token = null
user = null user = null
clearSuspensionState()
} }
suspend fun persistSessionToStorage(loginResponse: LoginResponse) { suspend fun persistSessionToStorage(loginResponse: LoginResponse) {
@@ -596,6 +669,7 @@ object ApiClient {
settings.remove("current_fcm_token") settings.remove("current_fcm_token")
token = null token = null
user = null user = null
clearSuspensionState()
uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) } uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) }
UpdateSyncManager.resetInMemoryOnLogout() UpdateSyncManager.resetInMemoryOnLogout()
runCatching { IdentityKeyManager.clearLocalKeys() } runCatching { IdentityKeyManager.clearLocalKeys() }
@@ -613,6 +687,7 @@ object ApiClient {
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) {
if (_suspensionState.value.isSuspended) return
http.post("${Config.apiBaseUrl}/send_message") { http.post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(SendMessageRequest(content = content, reply_to_id = replyToId)) setBody(SendMessageRequest(content = content, reply_to_id = replyToId))
@@ -621,6 +696,7 @@ object ApiClient {
// WebSocket send helpers // WebSocket send helpers
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) { suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
if (_suspensionState.value.isSuspended) return
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "sendMessage", type = "sendMessage",
@@ -640,6 +716,7 @@ object ApiClient {
} }
suspend fun editMessage(messageId: Int, content: String) { suspend fun editMessage(messageId: Int, content: String) {
if (_suspensionState.value.isSuspended) return
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "editMessage", type = "editMessage",
@@ -658,6 +735,7 @@ object ApiClient {
} }
suspend fun deleteMessage(messageId: Int) { suspend fun deleteMessage(messageId: Int) {
if (_suspensionState.value.isSuspended) return
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "deleteMessage", type = "deleteMessage",
@@ -675,6 +753,7 @@ object ApiClient {
} }
suspend fun sendTyping() { suspend fun sendTyping() {
if (_suspensionState.value.isSuspended) return
runCatching { runCatching {
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
@@ -689,6 +768,7 @@ object ApiClient {
} }
suspend fun sendStopTyping() { suspend fun sendStopTyping() {
if (_suspensionState.value.isSuspended) return
runCatching { runCatching {
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
@@ -703,6 +783,7 @@ object ApiClient {
} }
suspend fun sendDmTyping(recipientId: Int) { suspend fun sendDmTyping(recipientId: Int) {
if (_suspensionState.value.isSuspended) return
runCatching { runCatching {
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
@@ -718,6 +799,7 @@ object ApiClient {
} }
suspend fun sendStopDmTyping(recipientId: Int) { suspend fun sendStopDmTyping(recipientId: Int) {
if (_suspensionState.value.isSuspended) return
runCatching { runCatching {
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
@@ -33,7 +33,9 @@ data class User(
@SerialName("display_name") val displayName: String? = null, @SerialName("display_name") val displayName: String? = null,
val admin: Boolean? = null, val admin: Boolean? = null,
val bio: String? = 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 @Serializable
@@ -11,6 +11,23 @@ import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.concurrent.Volatile 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). * 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. * [put] copy-on-writes the map and schedules an async flush of the full list to settings.
@@ -293,16 +293,19 @@ object WebSocketManager {
} }
fun shutdown() { fun shutdown() {
disconnect()
logD("shutdown() called. Cancelling scope.") logD("shutdown() called. Cancelling scope.")
scope.cancel() scope.cancel()
} }
fun disconnect() { 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?.cancel()
session = null session = null
connecting = false 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() { fun onNetworkLost() {
@@ -103,10 +103,18 @@ object MessageCacheStore {
replaceMessages(conversationIdForPublic(), merged) replaceMessages(conversationIdForPublic(), merged)
} }
suspend fun clearPublicMessages() {
clearConversationMessages(conversationIdForPublic())
}
suspend fun loadDmMessages(otherUserId: Int): List<Message> { suspend fun loadDmMessages(otherUserId: Int): List<Message> {
return loadMessages(conversationIdForDm(otherUserId)) return loadMessages(conversationIdForDm(otherUserId))
} }
suspend fun clearDmMessages(otherUserId: Int) {
clearConversationMessages(conversationIdForDm(otherUserId))
}
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) { suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
val pending = loadPendingMessages(convId) val pending = loadPendingMessages(convId)
@@ -151,6 +159,12 @@ object MessageCacheStore {
confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed) 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) { private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId) db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId)
@@ -59,6 +59,8 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder 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.SettingsAccountScreen
import ru.fromchat.ui.main.settings.SettingsAppearanceScreen import ru.fromchat.ui.main.settings.SettingsAppearanceScreen
import ru.fromchat.ui.main.settings.SettingsDevicesScreen 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) { private fun handlePresenceEvent(message: WebSocketMessage) {
when (message.type) { when (message.type) {
"suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message)
"statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus) "statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus)
"dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) } "dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) }
"updates" -> { "updates" -> {
val data = message.data ?: return val data = message.data ?: return
val updates = ApiClient.json.decodeFromJsonElement<WebSocketUpdatesData>(data) val updates = ApiClient.json.decodeFromJsonElement<WebSocketUpdatesData>(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() } 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) // Initialize update sync state for the current user (if any)
runCatching { runCatching {
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
@@ -279,6 +317,7 @@ fun App(
composable("login") { composable("login") {
LoginScreen( LoginScreen(
onLoginSuccess = { onLoginSuccess = {
WebSocketManager.connect(forceRestart = true)
navController.navigate("chat") { navController.navigate("chat") {
popUpTo("login") { inclusive = true } popUpTo("login") { inclusive = true }
} }
@@ -11,6 +11,7 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -209,7 +211,9 @@ fun ChatInput(
onClearEdit: () -> Unit, onClearEdit: () -> Unit,
hazeState: HazeState, hazeState: HazeState,
recipientId: Int? = null, recipientId: Int? = null,
currentUserId: Int? = null currentUserId: Int? = null,
isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {}
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(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 cdClose = stringResource(Res.string.cd_close)
val cdRemove = stringResource(Res.string.cd_remove) val cdRemove = stringResource(Res.string.cd_remove)
val cdPickImage = stringResource(Res.string.cd_pick_image) val cdPickImage = stringResource(Res.string.cd_pick_image)
@@ -261,6 +265,7 @@ fun ChatInput(
val cdSend = stringResource(Res.string.cd_send) val cdSend = stringResource(Res.string.cd_send)
val corruptedShort = stringResource(Res.string.message_corrupted_short) val corruptedShort = stringResource(Res.string.message_corrupted_short)
val editingTitle = stringResource(Res.string.message_editing_title) val editingTitle = stringResource(Res.string.message_editing_title)
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -284,142 +289,163 @@ fun ChatInput(
state = hazeState, state = hazeState,
style = HazeMaterials.thin() style = HazeMaterials.thin()
) )
.clickable(enabled = isReadOnly) {
if (isReadOnly) {
onReadOnlyMessageClick()
}
}
) { ) {
AnimatedPreviewBar(replyTo) { replyTo -> if (isReadOnly) {
val replySubtitle = if (replyTo.isContentCorrupted) { Text(
corruptedShort text = blockedMessage,
} else { style = MaterialTheme.typography.bodyLarge,
replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" textAlign = TextAlign.Center,
} color = MaterialTheme.colorScheme.error,
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(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .padding(horizontal = 16.dp, vertical = 12.dp)
.padding(start = 12.dp, end = 12.dp, top = 8.dp), )
horizontalArrangement = Arrangement.spacedBy(8.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 -> Row(
AttachmentChip( modifier = Modifier
attachment = attachment, .fillMaxWidth()
onRemove = { attachments = attachments.filter { it.id != attachment.id } }, .horizontalScroll(rememberScrollState())
removeContentDescription = cdRemove .padding(start = 12.dp, end = 12.dp, top = 8.dp),
) horizontalArrangement = Arrangement.spacedBy(8.dp)
} ) {
} attachments.forEach { attachment ->
} AttachmentChip(
attachment = attachment,
Row( onRemove = { attachments = attachments.filter { it.id != attachment.id } },
modifier = Modifier.fillMaxWidth(), removeContentDescription = cdRemove
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)
) )
) { }
Box(Modifier.padding(end = 5.dp)) { }
FilledIconButton( }
onClick = {
val plaintext = text.trim().ifBlank { "" } Row(
onSend(plaintext, attachments) modifier = Modifier.fillMaxWidth(),
onTextChange("") verticalAlignment = Alignment.Bottom
attachments = emptyList() ) {
typingHandler.stopTyping() if (recipientId != null && !isReadOnly) {
}, IconButton(onClick = { launchImagePicker() }) {
modifier = Modifier.size(36.dp) Icon(
) { imageVector = Icons.Default.Image,
Icon( contentDescription = cdPickImage,
imageVector = Icons.AutoMirrored.Filled.Send, tint = MaterialTheme.colorScheme.onSurfaceVariant
contentDescription = cdSend, )
tint = MaterialTheme.colorScheme.onPrimary, }
modifier = Modifier.size(18.dp) 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)
)
}
} }
} }
} }
} )
) }
} }
} }
} }
@@ -94,6 +94,7 @@ import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.chat.getImageAspectRatio import ru.fromchat.ui.chat.getImageAspectRatio
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.rememberLastSeenFormatStrings import ru.fromchat.utils.rememberLastSeenFormatStrings
import kotlin.time.Clock import kotlin.time.Clock
@@ -145,7 +146,10 @@ fun ChatScreen(
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val suspensionState by ApiClient.suspensionState.collectAsState()
val isReadOnly = suspensionState.isSuspended
val lastSeenFormat = rememberLastSeenFormatStrings() val lastSeenFormat = rememberLastSeenFormatStrings()
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
val statusConnecting = stringResource(Res.string.status_connecting) val statusConnecting = stringResource(Res.string.status_connecting)
val statusUpdating = stringResource(Res.string.status_updating) val statusUpdating = stringResource(Res.string.status_updating)
val chatGroupLabel = stringResource(Res.string.chat_group_label) 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 }}") 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 // Subscribe to other user's status when DM is visible; unsubscribe on leave
LaunchedEffect(panelState.profileUserId) { LaunchedEffect(panelState.profileUserId) {
val userId = panelState.profileUserId val userId = panelState.profileUserId
@@ -236,7 +246,7 @@ fun ChatScreen(
"newMessage", "messageEdited", "messageDeleted", "newMessage", "messageEdited", "messageDeleted",
"dmNew", "dmEdited", "dmDeleted", "dmNew", "dmEdited", "dmDeleted",
"typing", "stopTyping", "dmTyping", "stopDmTyping", "typing", "stopTyping", "dmTyping", "stopDmTyping",
"suspended", "account_deleted", "registeredUserCount" -> { "registeredUserCount" -> {
Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}") Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}")
try { try {
panel.handleWebSocketMessage(wsMessage) panel.handleWebSocketMessage(wsMessage)
@@ -323,6 +333,11 @@ fun ChatScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
SuspendedAccountSupportSheet(
isVisible = isReadOnly && showSuspendedSupportSheet,
onDismissRequest = { showSuspendedSupportSheet = false }
)
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
@@ -526,7 +541,7 @@ fun ChatScreen(
} }
}, },
actions = { actions = {
if (panel.showCallButton()) { if (panel.showCallButton() && !isReadOnly) {
IconButton(onClick = { /* TODO: Handle call */ }) { IconButton(onClick = { /* TODO: Handle call */ }) {
Icon( Icon(
imageVector = Icons.Default.Call, imageVector = Icons.Default.Call,
@@ -642,7 +657,13 @@ fun ChatScreen(
inputText = "" inputText = ""
}, },
hazeState = hazeState, hazeState = hazeState,
recipientId = panel.getRecipientId() recipientId = panel.getRecipientId(),
isReadOnly = isReadOnly,
onReadOnlyMessageClick = {
if (isReadOnly) {
showSuspendedSupportSheet = true
}
}
) )
} }
} }
@@ -691,6 +712,9 @@ fun ChatScreen(
isContextMenuOpen = contextMenuState.isOpen, isContextMenuOpen = contextMenuState.isOpen,
isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id, isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id,
onLongPress = { onLongPress = {
if (isReadOnly) {
return@MessageItem
}
haptic(HapticFeedbackEvent.ContextMenuOpened) haptic(HapticFeedbackEvent.ContextMenuOpened)
contextMenuState = ContextMenuState( contextMenuState = ContextMenuState(
isOpen = true, isOpen = true,
@@ -756,6 +780,7 @@ fun ChatScreen(
MessageContextMenu( MessageContextMenu(
state = contextMenuState, state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId, isAuthor = contextMenuState.message?.user_id == currentUserId,
isReadOnly = isReadOnly,
screenWidthPx = screenWidthPx, screenWidthPx = screenWidthPx,
screenHeightPx = screenHeightPx, screenHeightPx = screenHeightPx,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) }, onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
@@ -815,4 +840,5 @@ fun ChatScreen(
) )
} }
} }
} }
@@ -67,10 +67,20 @@ fun MessageContextMenu(
onReply: (Message) -> Unit, onReply: (Message) -> Unit,
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit, onDelete: (Message) -> Unit,
isReadOnly: Boolean = false,
screenWidthPx: Int, screenWidthPx: Int,
screenHeightPx: Int, screenHeightPx: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (isReadOnly && state.isOpen) {
onDismiss()
return
}
if (isReadOnly) {
return
}
var shouldShowPopup by remember(state.message) { var shouldShowPopup by remember(state.message) {
mutableStateOf(state.isOpen && state.message != null) mutableStateOf(state.isOpen && state.message != null)
} }
@@ -119,6 +129,7 @@ fun MessageContextMenu(
modifier = modifier.graphicsLayer(alpha = 0f), modifier = modifier.graphicsLayer(alpha = 0f),
animated = false, animated = false,
withShadow = false, withShadow = false,
isReadOnly = isReadOnly,
) )
}.map { it.measure(looseConstraints) } }.map { it.measure(looseConstraints) }
val p = placeables.firstOrNull() val p = placeables.firstOrNull()
@@ -206,7 +217,8 @@ fun MessageContextMenu(
scale = scale, scale = scale,
alpha = alpha, alpha = alpha,
transformOriginX = transformOriginX, transformOriginX = transformOriginX,
transformOriginY = transformOriginY transformOriginY = transformOriginY,
isReadOnly = isReadOnly
) )
} }
} }
@@ -220,6 +232,7 @@ private fun ContextMenuContent(
onReply: (Message) -> Unit, onReply: (Message) -> Unit,
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit, onDelete: (Message) -> Unit,
isReadOnly: Boolean = false,
modifier: Modifier, modifier: Modifier,
animated: Boolean, animated: Boolean,
withShadow: Boolean = true, withShadow: Boolean = true,
@@ -272,19 +285,21 @@ private fun ContextMenuContent(
.verticalScroll(menuScrollState), .verticalScroll(menuScrollState),
verticalArrangement = Arrangement.spacedBy(itemSpacing) verticalArrangement = Arrangement.spacedBy(itemSpacing)
) { ) {
ContextMenuItem( if (!isReadOnly) {
icon = Icons.AutoMirrored.Filled.Reply, ContextMenuItem(
text = labelReply, icon = Icons.AutoMirrored.Filled.Reply,
onClick = { onReply(message) } text = labelReply,
) onClick = { onReply(message) }
if (isAuthor) { )
}
if (isAuthor && !isReadOnly) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Edit, icon = Icons.Default.Edit,
text = labelEdit, text = labelEdit,
onClick = { onEdit(message) } onClick = { onEdit(message) }
) )
} }
if (isAuthor) { if (isAuthor && !isReadOnly) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Default.Delete, icon = Icons.Default.Delete,
text = labelDelete, text = labelDelete,
@@ -3,6 +3,8 @@ package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.visibleUsername
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.* import ru.fromchat.*
@@ -16,6 +18,18 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (currentUserId != null && message.user_id == currentUserId) { if (currentUserId != null && message.user_id == currentUserId) {
return stringResource(Res.string.message_sender_you) 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) val m = userIdUsernamePattern.matchEntire(message.username)
if (m != null) { if (m != null) {
val id = m.groupValues[1].toIntOrNull() val id = m.groupValues[1].toIntOrNull()
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import io.ktor.client.plugins.ClientRequestException
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient 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. // 2) Refresh from network; this may be fast or slow, but runs entirely off main.
val response = withContext(Dispatchers.Default) { val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) }.getOrNull() runCatching { ApiClient.getMessages(limit = 50) }
} }
val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) { if (response != null && response.messages.isNotEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val shown = _state.messages val shown = _state.messages
@@ -211,17 +214,33 @@ class PublicChatPanel(
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(response.messages) 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()) { } else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck. // Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else {
// We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} }
} }
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import io.ktor.client.plugins.ClientRequestException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -16,6 +17,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmEnvelope import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.visibleDisplayName
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
@@ -83,7 +85,7 @@ class DmPanel(
return@onSuccess return@onSuccess
} }
ProfileCache.put(profile) ProfileCache.put(profile)
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty()
otherDisplayName = displayName otherDisplayName = displayName
otherProfilePicture = profile.profilePicture otherProfilePicture = profile.profilePicture
updateState { updateState {
@@ -130,19 +132,15 @@ class DmPanel(
override suspend fun loadMessages() { override suspend fun loadMessages() {
setLoading(true) setLoading(true)
try { try {
// First, hydrate from cache if available. val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
runCatching { if (cached.isNotEmpty()) {
val cached = MessageCacheStore.loadDmMessages(otherUserId) clearMessages()
if (cached.isNotEmpty()) { addMessages(cached)
clearMessages()
addMessages(cached)
}
} }
// Then refresh from network. val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
runCatching { if (historyResult.isSuccess) {
ApiClient.getDmHistory(otherUserId) val response = historyResult.getOrNull() ?: return@loadMessages
}.onSuccess { response ->
clearMessages() clearMessages()
val decryptedForLog = mutableListOf<Pair<Int, String>>() val decryptedForLog = mutableListOf<Pair<Int, String>>()
val messages = response.messages.map { envelope -> val messages = response.messages.map { envelope ->
@@ -165,8 +163,14 @@ class DmPanel(
// Persist the most recent DM messages for offline use. // Persist the most recent DM messages for offline use.
MessageCacheStore.replaceDmMessages(otherUserId, messagesWithReplies) MessageCacheStore.replaceDmMessages(otherUserId, messagesWithReplies)
}.onFailure { error -> } else {
Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error) 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 { } finally {
setLoading(false) setLoading(false)
@@ -67,6 +67,7 @@ import ru.fromchat.resolveSearchHintResource
import ru.fromchat.resolveSearchTitleResource import ru.fromchat.resolveSearchTitleResource
import ru.fromchat.resolveSearchNotFoundMessageResource import ru.fromchat.resolveSearchNotFoundMessageResource
import ru.fromchat.resolveSearchNotFoundTitleResource import ru.fromchat.resolveSearchNotFoundTitleResource
import ru.fromchat.api.visibleUsername
import ru.fromchat.ui.BackHandler import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.M3SearchBar import ru.fromchat.ui.M3SearchBar
import ru.fromchat.ui.SearchBarSharedElement import ru.fromchat.ui.SearchBarSharedElement
@@ -271,7 +272,7 @@ private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery
val candidates = buildList { val candidates = buildList {
add(conv.displayName) add(conv.displayName)
cached?.displayName?.let { add(it) } cached?.displayName?.let { add(it) }
cached?.username?.let { add(it) } cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) }
} }
return candidates.any { candidate -> return candidates.any { candidate ->
@@ -79,9 +79,12 @@ import ru.fromchat.ui.SearchBarSharedElement
import ru.fromchat.ui.branding.FromChatBrandTitle import ru.fromchat.ui.branding.FromChatBrandTitle
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.suspension.*
import ru.fromchat.ui.dm.DmNav import ru.fromchat.ui.dm.DmNav
import ru.fromchat.unread_count import ru.fromchat.unread_count
import ru.fromchat.user_fallback import ru.fromchat.user_fallback
import ru.fromchat.api.visibleUsername
import ru.fromchat.*
@Composable @Composable
private fun ChatRowAvatar( private fun ChatRowAvatar(
@@ -126,6 +129,7 @@ fun ChatsTab(
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope() val statusSubscriptionScope = rememberCoroutineScope()
val suspensionState by ApiClient.suspensionState.collectAsState()
val publicConversationsOffset = 1 val publicConversationsOffset = 1
LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) { LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) {
@@ -202,6 +206,8 @@ fun ChatsTab(
val updatingTitle = stringResource(Res.string.status_updating) val updatingTitle = stringResource(Res.string.status_updating)
val brandTitle = stringResource(Res.string.app_name) val brandTitle = stringResource(Res.string.app_name)
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) 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) val publicChatTitle = stringResource(Res.string.public_chat)
Scaffold( Scaffold(
@@ -320,6 +326,15 @@ fun ChatsTab(
sharedElementKey = SearchBarSharedElement 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( ChatConversationsList(
listState = tabListState, listState = tabListState,
conversations = dmConversations, conversations = dmConversations,
@@ -342,6 +357,7 @@ fun ChatsTab(
) )
} }
} }
} }
@Composable @Composable
@@ -439,7 +455,7 @@ private fun DmConversationRow(
val cached = ProfileCache.get(conversation.otherUserId) val cached = ProfileCache.get(conversation.otherUserId)
val avatarUrl = cached?.profilePicture val avatarUrl = cached?.profilePicture
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() } ?: cached?.visibleUsername(ApiClient.user?.id)
?: conversation.displayName.ifBlank { ?: conversation.displayName.ifBlank {
stringResource(Res.string.user_fallback, conversation.otherUserId) stringResource(Res.string.user_fallback, conversation.otherUserId)
} }
@@ -511,7 +527,7 @@ private fun matchesDmSearch(conv: CachedConversation, normalizedQuery: String):
val candidates = buildList { val candidates = buildList {
add(conv.displayName) add(conv.displayName)
cached?.displayName?.let { add(it) } cached?.displayName?.let { add(it) }
cached?.username?.let { add(it) } cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) }
} }
return candidates.any { candidate -> return candidates.any { candidate ->
@@ -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<ChatsTabBannerCandidate>,
modifier: Modifier = Modifier
) {
val dismissedByUser = remember { mutableStateMapOf<String, Boolean>() }
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"
)
}
}
}
}
}
@@ -1954,7 +1954,7 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePass
onClick = { onClick = {
scope.launch { scope.launch {
runCatching { ApiClient.logout() } runCatching { ApiClient.logout() }
WebSocketManager.shutdown() WebSocketManager.disconnect()
onLogout() onLogout()
} }
}, },
@@ -1992,7 +1992,7 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePass
scope.launch { scope.launch {
runCatching { runCatching {
ApiClient.deleteAccount() ApiClient.deleteAccount()
WebSocketManager.shutdown() WebSocketManager.disconnect()
ApiClient.clearLocalSession() ApiClient.clearLocalSession()
onLogout() onLogout()
}.onFailure { }.onFailure {
@@ -75,6 +75,8 @@ import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile import ru.fromchat.api.UserProfile
import ru.fromchat.api.visibleDisplayName
import ru.fromchat.api.visibleUsername
import ru.fromchat.api.UserStatus import ru.fromchat.api.UserStatus
import ru.fromchat.api.UserStatusStore import ru.fromchat.api.UserStatusStore
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
@@ -232,10 +234,12 @@ fun ProfileScreen(
) { ) {
val loadError = state.error val loadError = state.error
val profile = state.profile val profile = state.profile
val displayName = profile?.displayName?.takeIf { it.isNotBlank() } val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
?: profile?.username?.takeIf { it.isNotBlank() } val displayName =
profile?.visibleDisplayName(currentProfileUserId)
?: initialDisplayName?.takeIf { it.isNotBlank() } ?: initialDisplayName?.takeIf { it.isNotBlank() }
?: "?" ?: "?"
val usernameForLinks = profile?.visibleUsername(currentProfileUserId)
val navSharedAvatarKey = val navSharedAvatarKey =
if (useSharedElementFromNavigation && targetUserId != null && sharedSourceMessageId != -1) { if (useSharedElementFromNavigation && targetUserId != null && sharedSourceMessageId != -1) {
@@ -334,7 +338,7 @@ fun ProfileScreen(
if (compactIdentityForPublicChat) { if (compactIdentityForPublicChat) {
"https://fromchat.ru/?u=${profile.id}" "https://fromchat.ru/?u=${profile.id}"
} else { } else {
profile.username.takeIf { it.isNotBlank() } usernameForLinks
?.let { "https://fromchat.ru/@$it" } ?.let { "https://fromchat.ru/@$it" }
?: "https://fromchat.ru/?u=${profile.id}" ?: "https://fromchat.ru/?u=${profile.id}"
} }
@@ -515,7 +519,7 @@ fun ProfileScreen(
} }
val showDetailsUsername = val showDetailsUsername =
!compactIdentityForPublicChat && profile.username.isNotBlank() !compactIdentityForPublicChat && usernameForLinks != null
val showDetailsMemberSince = val showDetailsMemberSince =
!compactIdentityForPublicChat && !compactIdentityForPublicChat &&
!profile.createdAt.isNullOrBlank() !profile.createdAt.isNullOrBlank()
@@ -531,7 +535,7 @@ fun ProfileScreen(
if (showDetailsUsername) { if (showDetailsUsername) {
ListItem( ListItem(
headline = headlineUsername, headline = headlineUsername,
supportingText = profile.username, supportingText = usernameForLinks.orEmpty(),
divider = true, divider = true,
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness, dividerThickness = CategoryDefaults.dividerThickness,
@@ -151,11 +151,11 @@ fun ServerConfigScreen() {
} }
// Clear API client state // Clear API client state
ApiClient.token = null ApiClient.clearMemorySession()
ApiClient.user = null
// Shutdown WebSocket (will reconnect on login) // Restart websocket connection flow so it uses the new server config
WebSocketManager.shutdown() WebSocketManager.disconnect()
WebSocketManager.connect(forceRestart = true)
// Navigate to login and wipe entire back stack // Navigate to login and wipe entire back stack
navController.navigateAndWipeBackStack("login") navController.navigateAndWipeBackStack("login")
@@ -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 }
)
}