mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement suspension state and global banners
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -204,4 +204,12 @@
|
||||
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
|
||||
<string name="more">Ещё</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>
|
||||
|
||||
@@ -238,4 +238,12 @@
|
||||
|
||||
<string name="more">More</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>
|
||||
|
||||
@@ -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> = _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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -103,10 +103,18 @@ object MessageCacheStore {
|
||||
replaceMessages(conversationIdForPublic(), merged)
|
||||
}
|
||||
|
||||
suspend fun clearPublicMessages() {
|
||||
clearConversationMessages(conversationIdForPublic())
|
||||
}
|
||||
|
||||
suspend fun loadDmMessages(otherUserId: Int): List<Message> {
|
||||
return loadMessages(conversationIdForDm(otherUserId))
|
||||
}
|
||||
|
||||
suspend fun clearDmMessages(otherUserId: Int) {
|
||||
clearConversationMessages(conversationIdForDm(otherUserId))
|
||||
}
|
||||
|
||||
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
|
||||
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)
|
||||
|
||||
@@ -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<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() }
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<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 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Pair<Int, String>>()
|
||||
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)
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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<Set<Int>>(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 ->
|
||||
|
||||
@@ -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 = {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user