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="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
|
||||||
@@ -186,7 +196,7 @@ fun ChatScreen(
|
|||||||
var inputText by rememberSaveable { mutableStateOf("") }
|
var inputText by rememberSaveable { mutableStateOf("") }
|
||||||
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
|
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
|
||||||
var editingMessage by rememberSaveable { mutableStateOf<Message?>(null) }
|
var editingMessage by rememberSaveable { mutableStateOf<Message?>(null) }
|
||||||
var contextMenuState by remember {
|
var contextMenuState by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ContextMenuState(
|
ContextMenuState(
|
||||||
isOpen = false,
|
isOpen = false,
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user